opencode-sesh 0.6.0 → 0.7.1
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 +20 -11
- package/CHANGELOG.md +28 -1
- package/README.md +8 -4
- package/bin/sesh +4 -0
- package/bin/sesh-cmux-sync.sh +149 -0
- package/contrib/cmux/README.md +13 -0
- package/install.sh +1 -0
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +74 -73
package/AGENTS.md
CHANGED
|
@@ -147,17 +147,26 @@ 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
|
-
- **
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
must never name a workspace: the pre-first-turn placeholder
|
|
164
|
+
(`/^New session\b/`) and plugin sentinels (`ghost-hidden`, which
|
|
165
|
+
opencode-ghost titles its internal sessions). `bin/sesh-cmux-sync.sh` does
|
|
166
|
+
the same for every workspace at once. The session title is the source of
|
|
167
|
+
truth; a manual cmux rename is overwritten on the next session-title change.
|
|
168
|
+
These are the only cmux calls in the panel; keep them behind the
|
|
169
|
+
`CMUX_WORKSPACE_ID` guard.
|
|
161
170
|
- **Picker:** a custom dialog (not `DialogSelect`) grouped by
|
|
162
171
|
project/directory, recency-ordered, resume via
|
|
163
172
|
`api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.1] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Workspace naming no longer pushes the `ghost-hidden` sentinel that the
|
|
15
|
+
opencode-ghost plugin uses for its internal sessions. The placeholder check
|
|
16
|
+
was also corrected, so `New session - …` titles are skipped as intended
|
|
17
|
+
rather than slipping through.
|
|
18
|
+
## [0.7.0] - 2026-09-24
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- The sidebar renames its cmux workspace to the current session title, so the
|
|
23
|
+
workspace list matches the session. Pre-first-turn placeholder titles
|
|
24
|
+
(`New session - …`) are never pushed.
|
|
25
|
+
- `sesh cmux-sync [--dry-run] [--json]` names every cmux workspace after the
|
|
26
|
+
opencode session it is running, using cmux's own per-surface resume record.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Click-to-focus now reads cmux's per-surface resume record (`checkpoint_id`)
|
|
31
|
+
instead of heartbeat files, so it also finds background agents and panes
|
|
32
|
+
running an older panel, and has no files to sweep.
|
|
33
|
+
|
|
10
34
|
## [0.6.0] - 2026-09-24
|
|
11
35
|
|
|
12
36
|
### Added
|
|
@@ -16,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
40
|
pane. Panes advertise their open session (cmux-only, one heartbeat file per
|
|
17
41
|
session, ~45 s TTL); outside cmux or with no live claimant, the click opens
|
|
18
42
|
the session locally as before.
|
|
43
|
+
|
|
19
44
|
## [0.5.0] - 2026-09-24
|
|
20
45
|
|
|
21
46
|
### Added
|
|
@@ -277,7 +302,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
277
302
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
278
303
|
CI.
|
|
279
304
|
|
|
280
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
305
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.1...HEAD
|
|
306
|
+
[0.7.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.0...v0.7.1
|
|
307
|
+
[0.7.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.6.0...v0.7.0
|
|
281
308
|
[0.6.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.5.0...v0.6.0
|
|
282
309
|
[0.5.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.4.0...v0.5.0
|
|
283
310
|
[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
|
|
192
|
-
workspace is already
|
|
193
|
-
that workspace instead of opening a duplicate
|
|
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
|
@@ -0,0 +1,149 @@
|
|
|
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 != "")
|
|
115
|
+
# Never push a placeholder or a plugin sentinel (opencode-ghost titles its
|
|
116
|
+
# internal sessions "ghost-hidden").
|
|
117
|
+
| select(($t | test("^New session\\b|^ghost-hidden$"; "i") | not))
|
|
118
|
+
| select(($shown[.workspace] // "") != $t)
|
|
119
|
+
| . + {title: $t} ]')
|
|
120
|
+
|
|
121
|
+
COUNT=$(printf '%s' "$FINAL" | "$JQ_BIN" 'length')
|
|
122
|
+
if [ "$COUNT" = 0 ]; then
|
|
123
|
+
echo 'Every cmux workspace already matches its session.'
|
|
124
|
+
exit 0
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
if [ "$emit_json" = 1 ]; then
|
|
128
|
+
printf '%s\n' "$FINAL"
|
|
129
|
+
exit 0
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
printf '%s' "$FINAL" | "$JQ_BIN" -r '.[] | "\(.workspace)\t\(.title)"' | while IFS=$'\t' read -r ws title; do
|
|
133
|
+
printf '%s → %s\n' "$ws" "$title"
|
|
134
|
+
done
|
|
135
|
+
|
|
136
|
+
if [ "$dry_run" = 1 ]; then
|
|
137
|
+
printf 'Would rename %s workspace(s) (--dry-run: nothing changed).\n' "$COUNT" >&2
|
|
138
|
+
exit 0
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
renamed=0
|
|
142
|
+
while IFS=$'\t' read -r ws title; do
|
|
143
|
+
if "$CMUX_BIN" workspace rename "$ws" --title "$title" >/dev/null 2>&1; then
|
|
144
|
+
renamed=$((renamed + 1))
|
|
145
|
+
else
|
|
146
|
+
echo "sesh: could not rename $ws" >&2
|
|
147
|
+
fi
|
|
148
|
+
done < <(printf '%s' "$FINAL" | "$JQ_BIN" -r '.[] | [.workspace, .title] | @tsv')
|
|
149
|
+
echo "Renamed $renamed workspace(s)."
|
package/contrib/cmux/README.md
CHANGED
|
@@ -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
package/package.json
CHANGED
package/tui/sesh-panel.tsx
CHANGED
|
@@ -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,
|
|
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,10 @@ 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
|
+
// Titles that must never become a cmux workspace name: opencode's pre-first-
|
|
181
|
+
// turn placeholder, and the sentinel other plugins (opencode-ghost) use for
|
|
182
|
+
// internal sessions so their scaffolding rows stay out of the UI.
|
|
183
|
+
const PLACEHOLDER_TITLE = /^New session\b|^ghost-hidden$/i
|
|
180
184
|
|
|
181
185
|
function shortDir(dir: string, home: string): string {
|
|
182
186
|
if (!dir || dir === "/") return "other"
|
|
@@ -390,49 +394,62 @@ async function queryWaitingIds(db: any): Promise<Map<string, string>> {
|
|
|
390
394
|
return out
|
|
391
395
|
}
|
|
392
396
|
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
// of opening a duplicate here.
|
|
396
|
-
// (
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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 }
|
|
397
|
+
// cmux bridge: which surface is running which agent session, so opening a
|
|
398
|
+
// session that is live in another cmux workspace focuses that workspace
|
|
399
|
+
// instead of opening a duplicate here. The truth is cmux's own resume record
|
|
400
|
+
// (`checkpoint_id` per surface), not anything this panel writes — so it also
|
|
401
|
+
// covers background agents and panes running an older panel. Outside cmux
|
|
402
|
+
// (no CMUX_WORKSPACE_ID) no cmux call is made.
|
|
403
|
+
type CmuxSurface = { ref: string; workspaceRef: string; here: boolean; checkpoint?: string }
|
|
404
|
+
|
|
405
|
+
async function cmuxJson(args: string[]): Promise<any | undefined> {
|
|
407
406
|
try {
|
|
408
|
-
|
|
407
|
+
const { stdout } = await promisify(execFile)("cmux", args)
|
|
408
|
+
return JSON.parse(stdout)
|
|
409
409
|
} catch {
|
|
410
410
|
return undefined
|
|
411
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
412
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
} catch {
|
|
429
|
-
// ignore races with other panes
|
|
413
|
+
|
|
414
|
+
async function cmuxSurfaces(): Promise<CmuxSurface[]> {
|
|
415
|
+
const tree = await cmuxJson(["tree", "--json", "--all"])
|
|
416
|
+
if (!tree) return []
|
|
417
|
+
const surfaces: CmuxSurface[] = []
|
|
418
|
+
for (const w of tree.windows ?? []) {
|
|
419
|
+
for (const ws of w.workspaces ?? []) {
|
|
420
|
+
if (typeof ws?.ref !== "string") continue
|
|
421
|
+
for (const pane of ws.panes ?? []) {
|
|
422
|
+
for (const s of pane.surfaces ?? []) {
|
|
423
|
+
if (s?.type === "terminal" && typeof s.ref === "string") {
|
|
424
|
+
surfaces.push({ ref: s.ref, workspaceRef: ws.ref, here: s.here === true })
|
|
430
425
|
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
// missing directory or concurrent cleanup; presence is best-effort
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
435
429
|
}
|
|
430
|
+
await Promise.all(
|
|
431
|
+
surfaces.map(async (surface) => {
|
|
432
|
+
const info = await cmuxJson(["surface", "resume", "show", "--surface", surface.ref, "--json"])
|
|
433
|
+
const checkpoint = info?.restore_record?.checkpoint_id
|
|
434
|
+
if (typeof checkpoint === "string") surface.checkpoint = checkpoint
|
|
435
|
+
}),
|
|
436
|
+
)
|
|
437
|
+
return surfaces
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Pure selection: the workspace to focus for a session, or undefined to open
|
|
441
|
+
// locally. Prefers a live surface in a different workspace; ignores the
|
|
442
|
+
// caller's own surface and workspace (focusing where you already are is a
|
|
443
|
+
// no-op, so a local open is the better answer).
|
|
444
|
+
function pickFocusWorkspace(surfaces: CmuxSurface[], sessionID: string): string | undefined {
|
|
445
|
+
const self = surfaces.find((surface) => surface.here)
|
|
446
|
+
for (const surface of surfaces) {
|
|
447
|
+
if (self && surface.ref === self.ref) continue
|
|
448
|
+
if (surface.checkpoint !== sessionID) continue
|
|
449
|
+
if (self && surface.workspaceRef === self.workspaceRef) continue
|
|
450
|
+
return surface.workspaceRef
|
|
451
|
+
}
|
|
452
|
+
return undefined
|
|
436
453
|
}
|
|
437
454
|
|
|
438
455
|
function addTranscript(index: Map<string, string>, sid: string, data: string): void {
|
|
@@ -708,6 +725,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
708
725
|
const [pendingDelete, setPendingDelete] = createSignal<string>()
|
|
709
726
|
const [waiting, setWaiting] = createSignal<Map<string, string>>(new Map())
|
|
710
727
|
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
728
|
+
let lastSyncedTitle: string | undefined
|
|
711
729
|
const currentID = createMemo(() => {
|
|
712
730
|
const route = props.api.route.current
|
|
713
731
|
if (route.name !== "session") return undefined
|
|
@@ -743,30 +761,20 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
743
761
|
} catch {
|
|
744
762
|
// leave the previous waiting set in place rather than flashing
|
|
745
763
|
}
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
764
|
+
// Keep this cmux workspace named after the session it is showing, so the
|
|
765
|
+
// sidebar and the workspace list agree. The session title is the source
|
|
766
|
+
// of truth; a placeholder title is never pushed. cmux-only.
|
|
749
767
|
try {
|
|
750
768
|
const workspace = process.env.CMUX_WORKSPACE_ID
|
|
751
769
|
const id = currentID()
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
await
|
|
755
|
-
|
|
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`))
|
|
770
|
+
const title = id ? (props.api.state.session.get(id)?.title ?? "").trim() : ""
|
|
771
|
+
if (workspace && title && !PLACEHOLDER_TITLE.test(title) && title !== lastSyncedTitle) {
|
|
772
|
+
await promisify(execFile)("cmux", ["workspace", "rename", workspace, "--title", title])
|
|
773
|
+
lastSyncedTitle = title
|
|
765
774
|
}
|
|
766
775
|
} catch {
|
|
767
|
-
//
|
|
776
|
+
// renaming is best-effort; a failure leaves the workspace name alone
|
|
768
777
|
}
|
|
769
|
-
if (process.env.CMUX_WORKSPACE_ID) void sweepPresence()
|
|
770
778
|
}
|
|
771
779
|
void load()
|
|
772
780
|
const clock = setInterval(() => void load(), POLL_MS)
|
|
@@ -845,26 +853,19 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
845
853
|
setSectionCollapsed((value) => !value)
|
|
846
854
|
}
|
|
847
855
|
|
|
848
|
-
// Open a session. If a
|
|
849
|
-
// workspace instead of opening a duplicate here
|
|
850
|
-
//
|
|
851
|
-
// local navigate. Only the sidebar goes through here.
|
|
856
|
+
// Open a session. If a cmux surface is already running it in another
|
|
857
|
+
// workspace, focus that workspace instead of opening a duplicate here;
|
|
858
|
+
// otherwise open it locally. `cmux` calls only run inside cmux.
|
|
852
859
|
const openSession = async (entry: Entry) => {
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
return
|
|
862
|
-
} catch {
|
|
863
|
-
// cmux unavailable; fall through to a local open
|
|
864
|
-
}
|
|
860
|
+
if (process.env.CMUX_WORKSPACE_ID) {
|
|
861
|
+
const target = pickFocusWorkspace(await cmuxSurfaces(), entry.id)
|
|
862
|
+
if (target) {
|
|
863
|
+
try {
|
|
864
|
+
await promisify(execFile)("cmux", ["workspace", "select", target])
|
|
865
|
+
return
|
|
866
|
+
} catch {
|
|
867
|
+
// cmux unavailable; fall through to a local open
|
|
865
868
|
}
|
|
866
|
-
} catch {
|
|
867
|
-
// no presence file for this session
|
|
868
869
|
}
|
|
869
870
|
}
|
|
870
871
|
props.api.route.navigate("session", { sessionID: entry.id })
|