opencode-sesh 0.6.0 → 0.7.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,17 +147,24 @@ 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
+ - **cmux bridge (cmux-only, opt-out by absence).** When `CMUX_WORKSPACE_ID` is
151
+ set the sidebar talks to the `cmux` CLI; outside cmux no cmux call is made and
152
+ every click opens locally. Two behaviors, both keyed on cmux's *own* per-
153
+ surface resume record (`cmux surface resume show` → `restore_record.
154
+ checkpoint_id`), not on anything sesh writes:
155
+ - **Click-to-focus.** `pickFocusWorkspace` (pure, covered by `tests/tui.mjs`)
156
+ scans `cmux tree --json --all` plus each surface's checkpoint and, when
157
+ another workspace runs the clicked session, runs
158
+ `cmux workspace select <ref>` instead of `api.route.navigate`. The caller's
159
+ own surface/workspace never match (a same-workspace focus is a no-op, so it
160
+ opens locally). Works for background agents and older panels too.
161
+ - **Workspace naming.** The poll renames this workspace to the current
162
+ session title (`cmux workspace rename <id> --title …`), skipping titles that
163
+ are still the pre-first-turn placeholder. `bin/sesh-cmux-sync.sh` does the
164
+ same for every workspace at once. The session title is the source of truth;
165
+ a manual cmux rename is overwritten on the next session-title change.
166
+ These are the only cmux calls in the panel; keep them behind the
167
+ `CMUX_WORKSPACE_ID` guard.
161
168
  - **Picker:** a custom dialog (not `DialogSelect`) grouped by
162
169
  project/directory, recency-ordered, resume via
163
170
  `api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
package/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-09-24
11
+
12
+ ### Added
13
+
14
+ - The sidebar renames its cmux workspace to the current session title, so the
15
+ workspace list matches the session. Pre-first-turn placeholder titles
16
+ (`New session - …`) are never pushed.
17
+ - `sesh cmux-sync [--dry-run] [--json]` names every cmux workspace after the
18
+ opencode session it is running, using cmux's own per-surface resume record.
19
+
20
+ ### Changed
21
+
22
+ - Click-to-focus now reads cmux's per-surface resume record (`checkpoint_id`)
23
+ instead of heartbeat files, so it also finds background agents and panes
24
+ running an older panel, and has no files to sweep.
10
25
  ## [0.6.0] - 2026-09-24
11
26
 
12
27
  ### Added
@@ -16,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
31
  pane. Panes advertise their open session (cmux-only, one heartbeat file per
17
32
  session, ~45 s TTL); outside cmux or with no live claimant, the click opens
18
33
  the session locally as before.
34
+
19
35
  ## [0.5.0] - 2026-09-24
20
36
 
21
37
  ### Added
@@ -277,7 +293,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
277
293
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
278
294
  CI.
279
295
 
280
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.6.0...HEAD
296
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.0...HEAD
297
+ [0.7.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.6.0...v0.7.0
281
298
  [0.6.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.5.0...v0.6.0
282
299
  [0.5.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.4.0...v0.5.0
283
300
  [0.4.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.3.0...v0.4.0
package/README.md CHANGED
@@ -145,6 +145,10 @@ per-project record.
145
145
  (`New session - …`) with the start of the session's first user message. Only
146
146
  placeholder titles are touched, and the same dry-run/`--yes` gating applies.
147
147
 
148
+ `sesh cmux-sync [--dry-run] [--json]` names every [cmux](https://cmux.dev)
149
+ workspace after the opencode session its pane is running, so the sidebar and the
150
+ workspace list agree. cmux-only; a no-op without the cmux CLI.
151
+
148
152
  When searching, title matches outrank transcript-only matches and the current
149
153
  project rises, so the list answers "where was that thing I worked on" before it
150
154
  answers "what is newest". Pins still win outright.
@@ -188,10 +192,10 @@ Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
188
192
  themselves to a **Needs input** group above the directories, with the count in
189
193
  the heading; `sesh --needs-input` lists the same set in the terminal.
190
194
  Hovering a row while keyboard navigation is active selects it for the next
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.
195
+ shortcut. Clicking (or Enter on) a session opens it here — unless another cmux
196
+ workspace is already running that session, in which case the sidebar focuses
197
+ that workspace instead of opening a duplicate (and the workspace is kept named
198
+ after its session). The full picker is one keystroke away.
195
199
 
196
200
  The full picker pages through your whole global session list (no fixed window)
197
201
  and indexes transcript text for every session in the background, showing
package/bin/sesh CHANGED
@@ -34,5 +34,9 @@ case ${1:-} in
34
34
  shift
35
35
  exec bash "$SCRIPT_DIR/sesh-retitle.sh" "$@"
36
36
  ;;
37
+ cmux-sync)
38
+ shift
39
+ exec bash "$SCRIPT_DIR/sesh-cmux-sync.sh" "$@"
40
+ ;;
37
41
  esac
38
42
  exec "$SCRIPT_DIR/sesh.sh" "$@"
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # Name each cmux workspace after the opencode session it is running.
3
+ #
4
+ # cmux records the session behind every terminal surface (its resume
5
+ # `checkpoint_id`), so the mapping needs no bookkeeping: for each surface whose
6
+ # checkpoint is an opencode session (`ses_…`), rename its workspace to that
7
+ # session's current title. Titles are the opencode session title (the source of
8
+ # truth) unless it is still the pre-first-turn placeholder, which is skipped.
9
+ #
10
+ # cmux-only: exits cleanly when the cmux CLI is unavailable. Renames are
11
+ # lossless (a title), so there is no confirmation; --dry-run previews instead.
12
+ #
13
+ # Usage: sesh-cmux-sync.sh [--dry-run] [--json]
14
+ set -euo pipefail
15
+
16
+ OPENCODE_BIN=${SESH_OPENCODE:-opencode}
17
+ CMUX_BIN=${SESH_CMUX:-cmux}
18
+ dry_run=0
19
+ emit_json=0
20
+ while [ "$#" -gt 0 ]; do
21
+ case "$1" in
22
+ --dry-run) dry_run=1 ;;
23
+ --json) emit_json=1 ;;
24
+ --help) echo "usage: ${0##*/} [--dry-run] [--json]"; exit 0 ;;
25
+ *) echo "unknown option: $1" >&2; exit 2 ;;
26
+ esac
27
+ shift
28
+ done
29
+
30
+ command -v "$CMUX_BIN" >/dev/null 2>&1 || { echo 'sesh: cmux is not available' >&2; exit 1; }
31
+ export CMUX_QUIET=1
32
+
33
+ resolve_db() {
34
+ if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
35
+ local from_cli=''
36
+ if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
37
+ from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
38
+ fi
39
+ if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
40
+ if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
41
+ return 1
42
+ }
43
+
44
+ SQLITE_BIN=''
45
+ if [ -n "${SESH_SQLITE:-}" ]; then
46
+ SQLITE_BIN=$SESH_SQLITE
47
+ elif command -v sqlite3 >/dev/null 2>&1; then
48
+ SQLITE_BIN=sqlite3
49
+ fi
50
+
51
+ JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
52
+ [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
53
+
54
+ DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
55
+ [ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
56
+
57
+ # 1. Every terminal surface cmux knows about, with its workspace.
58
+ SURFACES=$("$CMUX_BIN" tree --json --all 2>/dev/null | "$JQ_BIN" -c '
59
+ [ .windows[]?.workspaces[]? as $w
60
+ | $w.panes[]?.surfaces[]?
61
+ | select(.type == "terminal" and (.ref | type) == "string")
62
+ | {surface: .ref, workspace: $w.ref} ]') || SURFACES='[]'
63
+ [ -n "$SURFACES" ] || SURFACES='[]'
64
+
65
+ # 2. The opencode session each surface is running (cmux resume checkpoint).
66
+ PAIRS='[]'
67
+ while IFS=$'\t' read -r surface workspace; do
68
+ [ -n "$surface" ] || continue
69
+ checkpoint=$("$CMUX_BIN" surface resume show --surface "$surface" --json 2>/dev/null \
70
+ | "$JQ_BIN" -r '.restore_record.checkpoint_id // empty' 2>/dev/null || true)
71
+ case "$checkpoint" in
72
+ ses_[A-Za-z0-9]*) ;;
73
+ *) continue ;;
74
+ esac
75
+ PAIRS=$(printf '%s' "$PAIRS" | "$JQ_BIN" -c \
76
+ --arg ws "$workspace" --arg sid "$checkpoint" \
77
+ '. + [{workspace: $ws, session: $sid}]')
78
+ done < <(printf '%s' "$SURFACES" | "$JQ_BIN" -r '.[] | [.surface, .workspace] | @tsv')
79
+
80
+ if [ "$(printf '%s' "$PAIRS" | "$JQ_BIN" 'length')" = 0 ]; then
81
+ echo 'No cmux workspace is running an opencode session.'
82
+ exit 0
83
+ fi
84
+
85
+ # 3. Session titles (one query) and current workspace titles.
86
+ IN_LIST=''
87
+ for id in $(printf '%s' "$PAIRS" | "$JQ_BIN" -r '.[].session'); do
88
+ case "$id" in
89
+ ses_[A-Za-z0-9]*) IN_LIST="$IN_LIST'$id'," ;;
90
+ *) echo 'sesh: unexpected session id shape' >&2; exit 1 ;;
91
+ esac
92
+ done
93
+ IN_LIST=${IN_LIST%,}
94
+ if [ -n "$SQLITE_BIN" ]; then
95
+ TITLE_ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "SELECT id, title FROM session WHERE id IN ($IN_LIST);")
96
+ else
97
+ TITLE_ROWS=$("$OPENCODE_BIN" db "SELECT id, title FROM session WHERE id IN ($IN_LIST);" --format json)
98
+ fi
99
+ [ -n "$TITLE_ROWS" ] || TITLE_ROWS='[]'
100
+
101
+ CURRENT=$("$CMUX_BIN" workspace list --json 2>/dev/null | "$JQ_BIN" -c \
102
+ '[.workspaces[] | {workspace: .ref, title: (.custom_title // .title // "")}]') || CURRENT='[]'
103
+ [ -n "$CURRENT" ] || CURRENT='[]'
104
+
105
+ # 4. Keep only the real renames: a non-placeholder session title that differs
106
+ # from what the workspace already shows.
107
+ FINAL=$(printf '%s' "$PAIRS" | "$JQ_BIN" -c \
108
+ --slurpfile sessions <(printf '%s' "$TITLE_ROWS") \
109
+ --slurpfile current <(printf '%s' "$CURRENT") '
110
+ ($sessions[0] | map({key: .id, value: .title}) | from_entries) as $byId
111
+ | ($current[0] | map({key: .workspace, value: .title}) | from_entries) as $shown
112
+ | [ .[]
113
+ | ($byId[.session] // "" | gsub("^ +| +$"; "")) as $t
114
+ | select($t != "" and ($t | test("^New session\\b"; "i") | not))
115
+ | select(($shown[.workspace] // "") != $t)
116
+ | . + {title: $t} ]')
117
+
118
+ COUNT=$(printf '%s' "$FINAL" | "$JQ_BIN" 'length')
119
+ if [ "$COUNT" = 0 ]; then
120
+ echo 'Every cmux workspace already matches its session.'
121
+ exit 0
122
+ fi
123
+
124
+ if [ "$emit_json" = 1 ]; then
125
+ printf '%s\n' "$FINAL"
126
+ exit 0
127
+ fi
128
+
129
+ printf '%s' "$FINAL" | "$JQ_BIN" -r '.[] | "\(.workspace)\t\(.title)"' | while IFS=$'\t' read -r ws title; do
130
+ printf '%s → %s\n' "$ws" "$title"
131
+ done
132
+
133
+ if [ "$dry_run" = 1 ]; then
134
+ printf 'Would rename %s workspace(s) (--dry-run: nothing changed).\n' "$COUNT" >&2
135
+ exit 0
136
+ fi
137
+
138
+ renamed=0
139
+ while IFS=$'\t' read -r ws title; do
140
+ if "$CMUX_BIN" workspace rename "$ws" --title "$title" >/dev/null 2>&1; then
141
+ renamed=$((renamed + 1))
142
+ else
143
+ echo "sesh: could not rename $ws" >&2
144
+ fi
145
+ done < <(printf '%s' "$FINAL" | "$JQ_BIN" -r '.[] | [.workspace, .title] | @tsv')
146
+ echo "Renamed $renamed workspace(s)."
@@ -35,6 +35,19 @@ sesh --print --json --query "auth refactor" \
35
35
  Wrap that in a cmux `command` action (as above) or a shell function; it is
36
36
  caller-side on purpose, so sesh stays terminal-agnostic.
37
37
 
38
+ ## Keep workspace names in step
39
+
40
+ The sidebar renames its workspace to the opencode session title as you work, and
41
+ `sesh cmux-sync` does it for every workspace at once:
42
+
43
+ ```bash
44
+ sesh cmux-sync --dry-run # preview
45
+ sesh cmux-sync # rename to match
46
+ ```
47
+
48
+ It reads cmux's own per-surface resume record, so it also names workspaces whose
49
+ agent was started outside the sidebar.
50
+
38
51
  ## Needs-input triage from cmux
39
52
 
40
53
  `sesh --needs-input` prints (or `--json` emits) the sessions with unanswered
package/install.sh CHANGED
@@ -235,6 +235,7 @@ for path in \
235
235
  bin/sesh.sh \
236
236
  bin/sesh-list.sh \
237
237
  bin/sesh-preview.sh \
238
+ bin/sesh-cmux-sync.sh \
238
239
  bin/sesh-costs.sh \
239
240
  bin/sesh-delete.sh \
240
241
  bin/sesh-prune.sh \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.6.0",
3
+ "version": "0.7.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,7 +2,7 @@
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, readdir, rename, rmdir, unlink, writeFile } from "node:fs/promises"
5
+ import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
6
6
  import { dirname, join } from "node:path"
7
7
  import { execFile } from "node:child_process"
8
8
  import { promisify } from "node:util"
@@ -177,6 +177,9 @@ function pinnedFirst(entries: Entry[], pins: Pins): Entry[] {
177
177
  // dialog). Native `/sessions` and session_list (<leader>l) stay untouched.
178
178
  const BASE_MODE = "base"
179
179
  const POLL_MS = 15_000
180
+ // opencode names a session only after its first turn; until then the title is
181
+ // a placeholder that should not become a cmux workspace name.
182
+ const PLACEHOLDER_TITLE = /^New session\b/i
180
183
 
181
184
  function shortDir(dir: string, home: string): string {
182
185
  if (!dir || dir === "/") return "other"
@@ -390,49 +393,62 @@ async function queryWaitingIds(db: any): Promise<Map<string, string>> {
390
393
  return out
391
394
  }
392
395
 
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 }
396
+ // cmux bridge: which surface is running which agent session, so opening a
397
+ // session that is live in another cmux workspace focuses that workspace
398
+ // instead of opening a duplicate here. The truth is cmux's own resume record
399
+ // (`checkpoint_id` per surface), not anything this panel writes — so it also
400
+ // covers background agents and panes running an older panel. Outside cmux
401
+ // (no CMUX_WORKSPACE_ID) no cmux call is made.
402
+ type CmuxSurface = { ref: string; workspaceRef: string; here: boolean; checkpoint?: string }
403
+
404
+ async function cmuxJson(args: string[]): Promise<any | undefined> {
407
405
  try {
408
- parsed = JSON.parse(raw)
406
+ const { stdout } = await promisify(execFile)("cmux", args)
407
+ return JSON.parse(stdout)
409
408
  } catch {
410
409
  return undefined
411
410
  }
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
411
  }
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
412
+
413
+ async function cmuxSurfaces(): Promise<CmuxSurface[]> {
414
+ const tree = await cmuxJson(["tree", "--json", "--all"])
415
+ if (!tree) return []
416
+ const surfaces: CmuxSurface[] = []
417
+ for (const w of tree.windows ?? []) {
418
+ for (const ws of w.workspaces ?? []) {
419
+ if (typeof ws?.ref !== "string") continue
420
+ for (const pane of ws.panes ?? []) {
421
+ for (const s of pane.surfaces ?? []) {
422
+ if (s?.type === "terminal" && typeof s.ref === "string") {
423
+ surfaces.push({ ref: s.ref, workspaceRef: ws.ref, here: s.here === true })
430
424
  }
431
- }),
432
- )
433
- } catch {
434
- // missing directory or concurrent cleanup; presence is best-effort
425
+ }
426
+ }
427
+ }
435
428
  }
429
+ await Promise.all(
430
+ surfaces.map(async (surface) => {
431
+ const info = await cmuxJson(["surface", "resume", "show", "--surface", surface.ref, "--json"])
432
+ const checkpoint = info?.restore_record?.checkpoint_id
433
+ if (typeof checkpoint === "string") surface.checkpoint = checkpoint
434
+ }),
435
+ )
436
+ return surfaces
437
+ }
438
+
439
+ // Pure selection: the workspace to focus for a session, or undefined to open
440
+ // locally. Prefers a live surface in a different workspace; ignores the
441
+ // caller's own surface and workspace (focusing where you already are is a
442
+ // no-op, so a local open is the better answer).
443
+ function pickFocusWorkspace(surfaces: CmuxSurface[], sessionID: string): string | undefined {
444
+ const self = surfaces.find((surface) => surface.here)
445
+ for (const surface of surfaces) {
446
+ if (self && surface.ref === self.ref) continue
447
+ if (surface.checkpoint !== sessionID) continue
448
+ if (self && surface.workspaceRef === self.workspaceRef) continue
449
+ return surface.workspaceRef
450
+ }
451
+ return undefined
436
452
  }
437
453
 
438
454
  function addTranscript(index: Map<string, string>, sid: string, data: string): void {
@@ -708,6 +724,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
708
724
  const [pendingDelete, setPendingDelete] = createSignal<string>()
709
725
  const [waiting, setWaiting] = createSignal<Map<string, string>>(new Map())
710
726
  let confirmTimer: ReturnType<typeof setTimeout> | undefined
727
+ let lastSyncedTitle: string | undefined
711
728
  const currentID = createMemo(() => {
712
729
  const route = props.api.route.current
713
730
  if (route.name !== "session") return undefined
@@ -743,30 +760,20 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
743
760
  } catch {
744
761
  // leave the previous waiting set in place rather than flashing
745
762
  }
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.
763
+ // Keep this cmux workspace named after the session it is showing, so the
764
+ // sidebar and the workspace list agree. The session title is the source
765
+ // of truth; a placeholder title is never pushed. cmux-only.
749
766
  try {
750
767
  const workspace = process.env.CMUX_WORKSPACE_ID
751
768
  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`))
769
+ const title = id ? (props.api.state.session.get(id)?.title ?? "").trim() : ""
770
+ if (workspace && title && !PLACEHOLDER_TITLE.test(title) && title !== lastSyncedTitle) {
771
+ await promisify(execFile)("cmux", ["workspace", "rename", workspace, "--title", title])
772
+ lastSyncedTitle = title
765
773
  }
766
774
  } catch {
767
- // presence is best-effort; a failed heartbeat only disables focusing
775
+ // renaming is best-effort; a failure leaves the workspace name alone
768
776
  }
769
- if (process.env.CMUX_WORKSPACE_ID) void sweepPresence()
770
777
  }
771
778
  void load()
772
779
  const clock = setInterval(() => void load(), POLL_MS)
@@ -845,26 +852,19 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
845
852
  setSectionCollapsed((value) => !value)
846
853
  }
847
854
 
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.
855
+ // Open a session. If a cmux surface is already running it in another
856
+ // workspace, focus that workspace instead of opening a duplicate here;
857
+ // otherwise open it locally. `cmux` calls only run inside cmux.
852
858
  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
- }
859
+ if (process.env.CMUX_WORKSPACE_ID) {
860
+ const target = pickFocusWorkspace(await cmuxSurfaces(), entry.id)
861
+ if (target) {
862
+ try {
863
+ await promisify(execFile)("cmux", ["workspace", "select", target])
864
+ return
865
+ } catch {
866
+ // cmux unavailable; fall through to a local open
865
867
  }
866
- } catch {
867
- // no presence file for this session
868
868
  }
869
869
  }
870
870
  props.api.route.navigate("session", { sessionID: entry.id })