@humanu/orchestra 0.5.2 → 0.5.3
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/install.js +151 -102
- package/package.json +5 -4
- package/resources/api/git.sh +359 -0
- package/resources/api/tmux.sh +1266 -0
- package/resources/prebuilt/macos-arm64/orchestra +0 -0
- package/resources/prebuilt/macos-intel/orchestra +0 -0
- package/resources/scripts/commands.sh +227 -0
- package/resources/scripts/gw-bridge.sh +1184 -0
- package/resources/scripts/gw.sh +148 -0
- package/resources/scripts/gwr.sh +171 -0
|
@@ -0,0 +1,1266 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
###############################################################################
|
|
4
|
+
# tmux.sh – Dedicated tmux session management API
|
|
5
|
+
# ---------------------------------------------------------------------------
|
|
6
|
+
# This script provides a clean API for all tmux session operations.
|
|
7
|
+
# It can be sourced by other modules that need tmux functionality.
|
|
8
|
+
###############################################################################
|
|
9
|
+
|
|
10
|
+
# Ensure we have access to core utilities
|
|
11
|
+
if ! declare -f repo_root >/dev/null 2>&1 && ! declare -f git_repo_root >/dev/null 2>&1; then
|
|
12
|
+
echo "Error: tmux.sh must be sourced from gw.sh or have core utilities available" >&2
|
|
13
|
+
return 1 2>/dev/null || exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# Use git_repo_root if available, otherwise fallback to repo_root
|
|
17
|
+
if declare -f git_repo_root >/dev/null 2>&1; then
|
|
18
|
+
repo_root() { git_repo_root; }
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# --------------------------- Tmux Core API ----------------------------------
|
|
22
|
+
|
|
23
|
+
# Session delimiter (hardcoded)
|
|
24
|
+
# Note: tmux session names cannot contain ':'; use a safe delimiter
|
|
25
|
+
ORCHESTRA_SESSION_DELIM="__"
|
|
26
|
+
|
|
27
|
+
# Helper: return delimiter
|
|
28
|
+
_tmux_delim() { echo "$ORCHESTRA_SESSION_DELIM"; }
|
|
29
|
+
|
|
30
|
+
# Helper: sanitize tmux session name for filesystem usage
|
|
31
|
+
_orchestra_history_key() {
|
|
32
|
+
local key="$1"
|
|
33
|
+
key="${key//\//_}"
|
|
34
|
+
key=$(echo "$key" | tr '[:space:]' '_')
|
|
35
|
+
key=$(echo "$key" | tr -c '[:alnum:]_-' '_')
|
|
36
|
+
while [[ "$key" == *"__"* ]]; do
|
|
37
|
+
key="${key//__/_}"
|
|
38
|
+
done
|
|
39
|
+
key="${key##_}"
|
|
40
|
+
key="${key%%_}"
|
|
41
|
+
echo "$key"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Helper: absolute path to the command hook script (if present)
|
|
45
|
+
_orchestra_command_hook() {
|
|
46
|
+
local root
|
|
47
|
+
root="$(repo_root)"
|
|
48
|
+
if [[ -z "$root" ]]; then
|
|
49
|
+
echo ""
|
|
50
|
+
return
|
|
51
|
+
fi
|
|
52
|
+
local hook="$root/shell/orchestra-command-hook.sh"
|
|
53
|
+
if [[ -f "$hook" ]]; then
|
|
54
|
+
echo "$hook"
|
|
55
|
+
else
|
|
56
|
+
echo ""
|
|
57
|
+
fi
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Source the command hook inside a tmux session to enable command history logging
|
|
61
|
+
_tmux_source_command_hook() {
|
|
62
|
+
local session="$1"
|
|
63
|
+
local hook
|
|
64
|
+
hook="$(_orchestra_command_hook)"
|
|
65
|
+
if [[ -z "$hook" ]]; then
|
|
66
|
+
return
|
|
67
|
+
fi
|
|
68
|
+
if ! tmux_session_exists "$session"; then
|
|
69
|
+
return
|
|
70
|
+
fi
|
|
71
|
+
local pane_cmd
|
|
72
|
+
pane_cmd=$(tmux display-message -t "${session}:0" -p '#{pane_current_command}' 2>/dev/null || echo "")
|
|
73
|
+
case "$pane_cmd" in
|
|
74
|
+
bash|zsh|sh|fish|dash|ksh)
|
|
75
|
+
;;
|
|
76
|
+
*)
|
|
77
|
+
return
|
|
78
|
+
;;
|
|
79
|
+
esac
|
|
80
|
+
# Send sourcing command to the primary pane. The hook is idempotent and will
|
|
81
|
+
# simply return if it's already been installed in that shell.
|
|
82
|
+
tmux send-keys -t "${session}:0" "source '$hook'" C-m 2>/dev/null || true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Helper: orchestra prefix including delimiter
|
|
86
|
+
_tmux_orch_prefix() { printf "orchestra%s" "$( _tmux_delim )"; }
|
|
87
|
+
|
|
88
|
+
# Helper: split a string by multi-char delimiter into bash array named by ref
|
|
89
|
+
# Usage: _tmux_split_by_delim "string" "::" out_array_name
|
|
90
|
+
_tmux_split_by_delim() {
|
|
91
|
+
local _s="$1" _d="$2" _ref="$3"
|
|
92
|
+
local _arr=()
|
|
93
|
+
if [[ -z "$_d" ]]; then
|
|
94
|
+
_arr=("$_s")
|
|
95
|
+
else
|
|
96
|
+
while :; do
|
|
97
|
+
if [[ "$_s" == *"$_d"* ]]; then
|
|
98
|
+
_arr+=("${_s%%"$_d"*}")
|
|
99
|
+
_s="${_s#*"$_d"}"
|
|
100
|
+
else
|
|
101
|
+
_arr+=("$_s")
|
|
102
|
+
break
|
|
103
|
+
fi
|
|
104
|
+
done
|
|
105
|
+
fi
|
|
106
|
+
# Use printf with %q to properly quote array elements for eval
|
|
107
|
+
local _quoted=()
|
|
108
|
+
local _item
|
|
109
|
+
for _item in "${_arr[@]}"; do
|
|
110
|
+
printf -v _q "%q" "$_item"
|
|
111
|
+
_quoted+=("$_q")
|
|
112
|
+
done
|
|
113
|
+
eval "$_ref=( ${_quoted[*]} )"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Check if tmux is available
|
|
118
|
+
tmux_available() {
|
|
119
|
+
have_cmd tmux
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Check if currently inside a tmux session
|
|
123
|
+
tmux_inside_session() {
|
|
124
|
+
[[ -n "${TMUX-}" ]]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Generate random readable name for tmux sessions
|
|
128
|
+
tmux_generate_readable_name() {
|
|
129
|
+
local adjectives=(
|
|
130
|
+
"swift" "brave" "clever" "gentle" "bright" "calm" "eager" "fierce" "happy" "kind"
|
|
131
|
+
"lively" "noble" "proud" "quick" "smart" "wise" "bold" "cool" "daring" "epic"
|
|
132
|
+
"fuzzy" "jolly" "lucky" "merry" "peppy" "rosy" "sunny" "zesty" "crisp" "fresh"
|
|
133
|
+
)
|
|
134
|
+
local animals=(
|
|
135
|
+
"bear" "wolf" "fox" "eagle" "hawk" "lion" "tiger" "panda" "otter" "seal"
|
|
136
|
+
"whale" "shark" "dolphin" "falcon" "raven" "deer" "moose" "lynx" "badger" "heron"
|
|
137
|
+
"phoenix" "dragon" "griffin" "unicorn" "pegasus" "kraken" "sphinx" "chimera" "hydra" "basilisk"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
local adj_idx=$((RANDOM % ${#adjectives[@]}))
|
|
141
|
+
local animal_idx=$((RANDOM % ${#animals[@]}))
|
|
142
|
+
|
|
143
|
+
echo "${adjectives[$adj_idx]}_${animals[$animal_idx]}"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# --------------------------- Session Management -----------------------------
|
|
147
|
+
|
|
148
|
+
# Create a new tmux session
|
|
149
|
+
# Usage: tmux_create_session <session_name> <working_directory>
|
|
150
|
+
tmux_create_session() {
|
|
151
|
+
local session_name="$1"
|
|
152
|
+
local working_dir="$2"
|
|
153
|
+
|
|
154
|
+
if ! tmux_available; then
|
|
155
|
+
err "tmux not installed"
|
|
156
|
+
return 1
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
# Get repository info from the working directory context
|
|
160
|
+
local repo_name=""
|
|
161
|
+
local branch_name=""
|
|
162
|
+
local old_pwd="$PWD"
|
|
163
|
+
|
|
164
|
+
# Change to working directory to get accurate git info
|
|
165
|
+
cd "$working_dir" 2>/dev/null || true
|
|
166
|
+
|
|
167
|
+
if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
|
|
168
|
+
repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
|
|
169
|
+
branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
# Restore original directory
|
|
173
|
+
cd "$old_pwd" 2>/dev/null || true
|
|
174
|
+
|
|
175
|
+
# Ensure orchestra prefix (configurable delimiter) on session name to mark origin
|
|
176
|
+
local ORCH_PREFIX
|
|
177
|
+
ORCH_PREFIX="$(_tmux_orch_prefix)"
|
|
178
|
+
if [[ "$session_name" != ${ORCH_PREFIX}* ]]; then
|
|
179
|
+
session_name="${ORCH_PREFIX}${session_name}"
|
|
180
|
+
fi
|
|
181
|
+
|
|
182
|
+
# Create session with custom Orchestra status configuration
|
|
183
|
+
tmux new-session -Ad -s "$session_name" -c "$working_dir" >/dev/null 2>&1 || {
|
|
184
|
+
err "Failed to create tmux session: $session_name"
|
|
185
|
+
return 1
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Customize the default status bar to include Orchestra info on the left
|
|
189
|
+
if [[ -n "$repo_name" ]]; then
|
|
190
|
+
# Set custom status-left with Orchestra branding without full path
|
|
191
|
+
# Example: Go back (detach): Ctrl+b,d | Copy (scroll) mode: Ctrl+b,[ | Worktree: main
|
|
192
|
+
local worktree_name=""
|
|
193
|
+
if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
|
|
194
|
+
worktree_name="$branch_name"
|
|
195
|
+
else
|
|
196
|
+
worktree_name="$(basename "$working_dir")"
|
|
197
|
+
fi
|
|
198
|
+
tmux set-option -t "$session_name" status-left "Go back (detach): Ctrl+b,d | Copy (scroll) mode: Ctrl+b,[ | Worktree: ${worktree_name}" >/dev/null 2>&1 || true
|
|
199
|
+
|
|
200
|
+
# Increase status-left length to accommodate the message
|
|
201
|
+
tmux set-option -t "$session_name" status-left-length 120 >/dev/null 2>&1 || true
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
echo "$session_name"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Generate a short hash from a path for unique session identification
|
|
208
|
+
# Usage: tmux_path_hash <path>
|
|
209
|
+
tmux_path_hash() {
|
|
210
|
+
local path="$1"
|
|
211
|
+
# Use MD5 hash of the path, take first 8 chars
|
|
212
|
+
# Works on both macOS and Linux
|
|
213
|
+
if command -v md5sum >/dev/null 2>&1; then
|
|
214
|
+
echo -n "$path" | md5sum | cut -c1-8
|
|
215
|
+
elif command -v md5 >/dev/null 2>&1; then
|
|
216
|
+
echo -n "$path" | md5 -q | cut -c1-8
|
|
217
|
+
else
|
|
218
|
+
# Fallback: use cksum if neither md5 available
|
|
219
|
+
echo -n "$path" | cksum | cut -d' ' -f1 | cut -c1-8
|
|
220
|
+
fi
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Alias for backward compatibility
|
|
224
|
+
tmux_repo_hash() {
|
|
225
|
+
tmux_path_hash "$1"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# Ensure a session exists for slug+name in worktree; prints session name
|
|
229
|
+
# Creates sessions with format: [worktreename]_[worktreetreehash]_[datetime]_[readable_name]
|
|
230
|
+
# Usage: tmux_ensure_session <slug> <name> <worktree_path>
|
|
231
|
+
tmux_ensure_session() {
|
|
232
|
+
local slug="$1"
|
|
233
|
+
local name="$2"
|
|
234
|
+
local wt="$3"
|
|
235
|
+
local d
|
|
236
|
+
d="$(_tmux_delim)"
|
|
237
|
+
local date_part time_part
|
|
238
|
+
date_part="$(date +%Y%m%d)"
|
|
239
|
+
time_part="$(date +%H%M%S)"
|
|
240
|
+
|
|
241
|
+
# Use a repo-scoped hash to avoid cross-repo collisions
|
|
242
|
+
# Hash the absolute worktree path (backward-compat listing supports old slug-hash)
|
|
243
|
+
local worktree_hash
|
|
244
|
+
worktree_hash="$(tmux_path_hash "$wt")"
|
|
245
|
+
|
|
246
|
+
# If name is provided, use it; otherwise generate a random readable name with auto_ prefix
|
|
247
|
+
if [[ -z "$name" || "$name" == "main" ]]; then
|
|
248
|
+
name="auto_$(tmux_generate_readable_name)"
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
local sess="${slug}${d}${worktree_hash}${d}${date_part}${d}${time_part}${d}${name}"
|
|
252
|
+
tmux_create_session "$sess" "$wt"
|
|
253
|
+
_tmux_source_command_hook "$sess"
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# Check if a session exists
|
|
257
|
+
# Usage: tmux_session_exists <session_name>
|
|
258
|
+
tmux_session_exists() {
|
|
259
|
+
local session_name="$1"
|
|
260
|
+
tmux_available && tmux has-session -t "$session_name" 2>/dev/null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
# Kill/delete a tmux session
|
|
264
|
+
# Usage: tmux_kill_session <session_name>
|
|
265
|
+
tmux_kill_session() {
|
|
266
|
+
local session_name="$1"
|
|
267
|
+
|
|
268
|
+
if ! tmux_available; then
|
|
269
|
+
err "tmux not installed"
|
|
270
|
+
return 1
|
|
271
|
+
fi
|
|
272
|
+
|
|
273
|
+
tmux kill-session -t "$session_name" 2>/dev/null || {
|
|
274
|
+
err "Failed to kill session: $session_name"
|
|
275
|
+
return 1
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Attach or switch to session
|
|
280
|
+
# Usage: tmux_attach_session <session_name>
|
|
281
|
+
tmux_attach_session() {
|
|
282
|
+
local sess="$1"
|
|
283
|
+
|
|
284
|
+
if ! tmux_available; then
|
|
285
|
+
err "tmux not installed"
|
|
286
|
+
return 1
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
_tmux_source_command_hook "$sess"
|
|
290
|
+
|
|
291
|
+
# Get repository info for terminal title and banner update
|
|
292
|
+
local repo_name=""
|
|
293
|
+
local branch_name=""
|
|
294
|
+
local session_dir=""
|
|
295
|
+
|
|
296
|
+
# Try to get the session's working directory
|
|
297
|
+
session_dir="$(tmux display-message -t "$sess" -p '#{pane_current_path}' 2>/dev/null || echo "")"
|
|
298
|
+
|
|
299
|
+
if [[ -n "$session_dir" ]] && [[ -d "$session_dir" ]]; then
|
|
300
|
+
# Get git info from session's directory
|
|
301
|
+
local old_pwd="$PWD"
|
|
302
|
+
cd "$session_dir" 2>/dev/null || true
|
|
303
|
+
|
|
304
|
+
if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
|
|
305
|
+
repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
|
|
306
|
+
branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
|
|
307
|
+
fi
|
|
308
|
+
|
|
309
|
+
cd "$old_pwd" 2>/dev/null || true
|
|
310
|
+
else
|
|
311
|
+
# Fallback to current directory
|
|
312
|
+
if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
|
|
313
|
+
repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
|
|
314
|
+
branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
|
|
315
|
+
fi
|
|
316
|
+
fi
|
|
317
|
+
|
|
318
|
+
# Set terminal window title (works in most terminal emulators)
|
|
319
|
+
if [[ -n "$repo_name" ]]; then
|
|
320
|
+
printf "\033]0;🎼 Orchestra: %s [%s]\007" "$repo_name" "$branch_name" >/dev/tty 2>/dev/null || true
|
|
321
|
+
fi
|
|
322
|
+
|
|
323
|
+
# Show welcome message when attaching
|
|
324
|
+
if [[ -n "$repo_name" ]]; then
|
|
325
|
+
tmux display-message -t "$sess" -d 2000 \
|
|
326
|
+
"🎼 Orchestra Session | Repository: $repo_name | Branch: $branch_name" 2>/dev/null || true
|
|
327
|
+
fi
|
|
328
|
+
|
|
329
|
+
# Ensure status-left shows Orchestra help without full path
|
|
330
|
+
local worktree_name=""
|
|
331
|
+
if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
|
|
332
|
+
worktree_name="$branch_name"
|
|
333
|
+
else
|
|
334
|
+
# Fallback to directory name of session path if available
|
|
335
|
+
if [[ -n "$session_dir" && -d "$session_dir" ]]; then
|
|
336
|
+
worktree_name="$(basename "$session_dir")"
|
|
337
|
+
else
|
|
338
|
+
worktree_name="$branch_name"
|
|
339
|
+
fi
|
|
340
|
+
fi
|
|
341
|
+
tmux set-option -t "$sess" status-left "Go back (detach): Ctrl+b,d | Copy (scroll) mode: Ctrl+b,[ | Worktree: ${worktree_name}" >/dev/null 2>&1 || true
|
|
342
|
+
tmux set-option -t "$sess" status-left-length 120 >/dev/null 2>&1 || true
|
|
343
|
+
|
|
344
|
+
if tmux_inside_session; then
|
|
345
|
+
tmux switch-client -t "$sess" >/dev/null 2>&1 || true
|
|
346
|
+
else
|
|
347
|
+
tmux attach -t "$sess" >/dev/null 2>&1 || true
|
|
348
|
+
fi
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
# Rename a tmux session while preserving the worktree prefix, repo hash, and datetime
|
|
352
|
+
# Usage: tmux_rename_session <old_session_name> <new_name>
|
|
353
|
+
tmux_rename_session() {
|
|
354
|
+
local old_session="$1"
|
|
355
|
+
local new_name="$2"
|
|
356
|
+
|
|
357
|
+
if ! tmux_available; then
|
|
358
|
+
err "tmux not installed"
|
|
359
|
+
return 1
|
|
360
|
+
fi
|
|
361
|
+
|
|
362
|
+
local d ORCH_PREFIX
|
|
363
|
+
d="$(_tmux_delim)"
|
|
364
|
+
ORCH_PREFIX="$(_tmux_orch_prefix)"
|
|
365
|
+
|
|
366
|
+
# Detect and strip orchestra prefix for parsing
|
|
367
|
+
local orch=""
|
|
368
|
+
local base="$old_session"
|
|
369
|
+
if [[ "$base" == ${ORCH_PREFIX}* ]]; then
|
|
370
|
+
orch="$ORCH_PREFIX"
|
|
371
|
+
base="${base#${ORCH_PREFIX}}"
|
|
372
|
+
fi
|
|
373
|
+
|
|
374
|
+
# Split by current delimiter and scan from the right to tolerate delimiter inside segments
|
|
375
|
+
local parts
|
|
376
|
+
_tmux_split_by_delim "$base" "$d" parts
|
|
377
|
+
|
|
378
|
+
local n=${#parts[@]}
|
|
379
|
+
local idx_time=-1 idx_date=-1 idx_hash=-1
|
|
380
|
+
local i
|
|
381
|
+
for (( i=n-1; i>=0; i-- )); do
|
|
382
|
+
if [[ ${parts[$i]} =~ ^[0-9]{6}$ ]]; then idx_time=$i; break; fi
|
|
383
|
+
done
|
|
384
|
+
if (( idx_time > 0 )) && [[ ${parts[$((idx_time-1))]} =~ ^[0-9]{8}$ ]]; then
|
|
385
|
+
idx_date=$((idx_time-1))
|
|
386
|
+
fi
|
|
387
|
+
if (( idx_date > 0 )) && [[ ${parts[$((idx_date-1))]} =~ ^[0-9a-f]{8}$ ]]; then
|
|
388
|
+
idx_hash=$((idx_date-1))
|
|
389
|
+
fi
|
|
390
|
+
|
|
391
|
+
local prefix=""
|
|
392
|
+
if (( idx_time >= 0 && idx_date >= 0 )); then
|
|
393
|
+
# Build prefix: slug + d + [hash + d] + date + d + time + d
|
|
394
|
+
local upto=$idx_time
|
|
395
|
+
local j
|
|
396
|
+
for (( j=0; j<=upto; j++ )); do
|
|
397
|
+
if (( j > 0 )); then prefix+="$d"; fi
|
|
398
|
+
prefix+="${parts[$j]}"
|
|
399
|
+
done
|
|
400
|
+
prefix+="$d"
|
|
401
|
+
|
|
402
|
+
# Debug logging for rename parsing
|
|
403
|
+
if [[ -n "${GW_DEBUG_RENAME-}" || -n "${DEBUG-}" ]]; then
|
|
404
|
+
>&2 echo "[orchestra] rename DEBUG: old='$old_session' base='$base' delim='$d'"
|
|
405
|
+
>&2 echo "[orchestra] parts: ${parts[*]}"
|
|
406
|
+
>&2 echo "[orchestra] idx_time=$idx_time idx_date=$idx_date idx_hash=$idx_hash"
|
|
407
|
+
>&2 echo "[orchestra] prefix='${orch}${prefix}' new_name='$new_name'"
|
|
408
|
+
fi
|
|
409
|
+
else
|
|
410
|
+
err "Invalid session format"
|
|
411
|
+
return 1
|
|
412
|
+
fi
|
|
413
|
+
|
|
414
|
+
local new_session="${orch}${prefix}${new_name}"
|
|
415
|
+
|
|
416
|
+
tmux rename-session -t "$old_session" "$new_session" 2>/dev/null || {
|
|
417
|
+
err "Failed to rename session"
|
|
418
|
+
return 1
|
|
419
|
+
}
|
|
420
|
+
>&2 echo "✏️ Renamed session to: $new_name"
|
|
421
|
+
return 0
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
# --------------------------- Session Discovery ------------------------------
|
|
425
|
+
|
|
426
|
+
# Get tmux sessions for a slug prefix; prints session names sorted by last attached/activity
|
|
427
|
+
# Expected session format: [worktreename]_[worktreehash]_[datetime]_[readable_name]
|
|
428
|
+
# Usage: tmux_list_sessions_for_slug <slug> [worktree_path]
|
|
429
|
+
tmux_list_sessions_for_slug() {
|
|
430
|
+
local slug="$1"
|
|
431
|
+
local worktree_path="${2:-}"
|
|
432
|
+
tmux_available || return 0
|
|
433
|
+
|
|
434
|
+
local d ORCH_PREFIX
|
|
435
|
+
d="$(_tmux_delim)"
|
|
436
|
+
ORCH_PREFIX="$(_tmux_orch_prefix)"
|
|
437
|
+
|
|
438
|
+
# If worktree_path provided, match both old (absolute-path hash) and new (slug hash)
|
|
439
|
+
if [[ -n "$worktree_path" ]]; then
|
|
440
|
+
local hash_slug hash_path
|
|
441
|
+
hash_slug="$(tmux_path_hash "$slug")"
|
|
442
|
+
hash_path="$(tmux_path_hash "$worktree_path")"
|
|
443
|
+
local p1_new="${ORCH_PREFIX}${slug}${d}${hash_slug}${d}"
|
|
444
|
+
local p2_new="${slug}${d}${hash_slug}${d}"
|
|
445
|
+
local p1_old="${ORCH_PREFIX}${slug}${d}${hash_path}${d}"
|
|
446
|
+
local p2_old="${slug}${d}${hash_path}${d}"
|
|
447
|
+
|
|
448
|
+
# List sessions with either hash variant
|
|
449
|
+
tmux list-sessions -F '#{session_name}|||#{session_last_attached}|||#{session_activity}' 2>/dev/null \
|
|
450
|
+
| sed 's/|||/\t/g' \
|
|
451
|
+
| awk -v a="$p1_new" -v b="$p2_new" -v c="$p1_old" -v d="$p2_old" 'BEGIN{FS="\t"} index($1, a)==1 || index($1, b)==1 || index($1, c)==1 || index($1, d)==1 {print $1"\t"$2"\t"$3}' \
|
|
452
|
+
| sort -t $'\t' -k2,2nr -k3,3nr \
|
|
453
|
+
| awk -F '\t' '{print $1}' | awk '!seen[$0]++' || true
|
|
454
|
+
else
|
|
455
|
+
# New-format prefix matching without hash: orchestra__slug__... or slug__...
|
|
456
|
+
local prefix1="${ORCH_PREFIX}${slug}${d}"
|
|
457
|
+
local prefix2="${slug}${d}"
|
|
458
|
+
tmux list-sessions -F '#{session_name}|||#{session_last_attached}|||#{session_activity}' 2>/dev/null \
|
|
459
|
+
| sed 's/|||/\t/g' \
|
|
460
|
+
| awk -v p1="$prefix1" -v p2="$prefix2" 'BEGIN{FS="\t"} index($1, p1)==1 || index($1, p2)==1 {print $1"\t"$2"\t"$3}' \
|
|
461
|
+
| sort -t $'\t' -k2,2nr -k3,3nr \
|
|
462
|
+
| awk -F '\t' '{print $1}' || true
|
|
463
|
+
fi
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# List all tmux sessions
|
|
467
|
+
# Usage: tmux_list_all_sessions
|
|
468
|
+
tmux_list_all_sessions() {
|
|
469
|
+
tmux_available || return 0
|
|
470
|
+
tmux list-sessions -F '#{session_name}' 2>/dev/null || true
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
# Find sessions matching a pattern
|
|
474
|
+
# Usage: tmux_find_session <pattern>
|
|
475
|
+
tmux_find_session() {
|
|
476
|
+
local pattern="$1"
|
|
477
|
+
|
|
478
|
+
if ! tmux_available; then
|
|
479
|
+
return 1
|
|
480
|
+
fi
|
|
481
|
+
|
|
482
|
+
# Try exact match first
|
|
483
|
+
if tmux_session_exists "$pattern"; then
|
|
484
|
+
echo "$pattern"
|
|
485
|
+
return 0
|
|
486
|
+
fi
|
|
487
|
+
|
|
488
|
+
# Try pattern match
|
|
489
|
+
tmux_list_all_sessions | grep -E "$pattern" | head -1 || true
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
# --------------------------- Session Information ----------------------------
|
|
493
|
+
|
|
494
|
+
# Helper function to format session display names
|
|
495
|
+
# Input: session_content (e.g., "opencode_fixing_auth_bug" or "my_feature_work")
|
|
496
|
+
# Output: "Opencode: Fixing Auth Bug" or "My Feature Work"
|
|
497
|
+
format_session_display_name() {
|
|
498
|
+
local session_content="$1"
|
|
499
|
+
|
|
500
|
+
# Known app prefixes and their display names
|
|
501
|
+
local known_apps=("opencode" "running" "ssh" "docker" "k8s" "git" "db")
|
|
502
|
+
local app_prefix=""
|
|
503
|
+
local description=""
|
|
504
|
+
|
|
505
|
+
# Check if session starts with a known app prefix
|
|
506
|
+
for app in "${known_apps[@]}"; do
|
|
507
|
+
if [[ "$session_content" =~ ^${app}_ ]]; then
|
|
508
|
+
app_prefix="$app"
|
|
509
|
+
# Remove the app prefix and following underscore
|
|
510
|
+
description="${session_content#${app}_}"
|
|
511
|
+
break
|
|
512
|
+
fi
|
|
513
|
+
done
|
|
514
|
+
|
|
515
|
+
# If no app prefix found, treat whole string as description
|
|
516
|
+
if [[ -z "$app_prefix" ]]; then
|
|
517
|
+
description="$session_content"
|
|
518
|
+
fi
|
|
519
|
+
|
|
520
|
+
# Convert underscores and hyphens to spaces and apply sentence case
|
|
521
|
+
description="$(echo "$description" | tr '_-' ' ')"
|
|
522
|
+
|
|
523
|
+
# Apply sentence case (capitalize first letter of each word)
|
|
524
|
+
description="$(echo "$description" | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2));}1')"
|
|
525
|
+
|
|
526
|
+
# Format final display name
|
|
527
|
+
if [[ -n "$app_prefix" ]]; then
|
|
528
|
+
# Capitalize app name and format as "App: Description"
|
|
529
|
+
local app_display="$(echo "$app_prefix" | awk '{print toupper(substr($1,1,1)) tolower(substr($1,2))}')"
|
|
530
|
+
echo "${app_display}: ${description}"
|
|
531
|
+
else
|
|
532
|
+
echo "$description"
|
|
533
|
+
fi
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
# Parse session name and format for display
|
|
537
|
+
# Input: session_name (format: worktreename_repohash_YYYYMMDD_HHMMSS_readable_name or old formats)
|
|
538
|
+
# Output: "App: Description" or formatted display name
|
|
539
|
+
# Usage: tmux_format_session_display <session_name>
|
|
540
|
+
tmux_format_session_display() {
|
|
541
|
+
local session_name="$1"
|
|
542
|
+
|
|
543
|
+
# Handle temporary renaming sessions first
|
|
544
|
+
if [[ "$session_name" =~ _renaming$ ]]; then
|
|
545
|
+
# Extract base name before _renaming suffix
|
|
546
|
+
local base_name="${session_name%_renaming}"
|
|
547
|
+
# Recursively process the base name to get proper display
|
|
548
|
+
tmux_format_session_display "$base_name"
|
|
549
|
+
return $?
|
|
550
|
+
fi
|
|
551
|
+
|
|
552
|
+
# Strip leading orchestra_ prefix if present
|
|
553
|
+
if [[ "$session_name" =~ ^orchestra_(.+)$ ]]; then
|
|
554
|
+
session_name="${BASH_REMATCH[1]}"
|
|
555
|
+
fi
|
|
556
|
+
|
|
557
|
+
# New format with repo hash: worktreename_repohash_YYYYMMDD_HHMMSS_readable_name
|
|
558
|
+
if [[ "$session_name" =~ ^[^_]+_[a-f0-9]{8}_([0-9]{8})_([0-9]{6})_(.+)$ ]]; then
|
|
559
|
+
local date_part="${BASH_REMATCH[1]}" # YYYYMMDD
|
|
560
|
+
local time_part="${BASH_REMATCH[2]}" # HHMMSS
|
|
561
|
+
local readable_name="${BASH_REMATCH[3]}" # readable_name
|
|
562
|
+
|
|
563
|
+
# Parse date: YYYYMMDD -> Jul 21
|
|
564
|
+
local year="${date_part:0:4}"
|
|
565
|
+
local month="${date_part:4:2}"
|
|
566
|
+
local day="${date_part:6:2}"
|
|
567
|
+
|
|
568
|
+
# Parse time: HHMMSS -> 12:30am
|
|
569
|
+
local hour="${time_part:0:2}"
|
|
570
|
+
local minute="${time_part:2:2}"
|
|
571
|
+
|
|
572
|
+
# Convert to 12-hour format
|
|
573
|
+
local ampm="am"
|
|
574
|
+
local display_hour="$hour"
|
|
575
|
+
if [[ "$hour" -ge 12 ]]; then
|
|
576
|
+
ampm="pm"
|
|
577
|
+
if [[ "$hour" -gt 12 ]]; then
|
|
578
|
+
display_hour=$((hour - 12))
|
|
579
|
+
fi
|
|
580
|
+
fi
|
|
581
|
+
if [[ "$hour" == "00" ]]; then
|
|
582
|
+
display_hour="12"
|
|
583
|
+
fi
|
|
584
|
+
|
|
585
|
+
# Remove leading zero from hour
|
|
586
|
+
display_hour="${display_hour#0}"
|
|
587
|
+
|
|
588
|
+
# Convert month number to name
|
|
589
|
+
local month_names=("" "Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec")
|
|
590
|
+
local month_name="${month_names[${month#0}]}"
|
|
591
|
+
|
|
592
|
+
# Remove leading zero from day
|
|
593
|
+
local display_day="${day#0}"
|
|
594
|
+
|
|
595
|
+
# Format the readable name with proper formatting
|
|
596
|
+
local formatted_name="$(format_session_display_name "$readable_name")"
|
|
597
|
+
|
|
598
|
+
echo "${formatted_name} (${month_name} ${display_day} ${display_hour}:${minute}${ampm})"
|
|
599
|
+
# Fallback: Try format without repo hash: appname_description_YYYYMMDD_HHMMSS
|
|
600
|
+
elif [[ "$session_name" =~ ^(.*)_[0-9]{8}_[0-9]{6}$ ]]; then
|
|
601
|
+
local session_content="${BASH_REMATCH[1]}" # appname_description
|
|
602
|
+
|
|
603
|
+
# Convert to proper display format with app prefix
|
|
604
|
+
local formatted_name="$(format_session_display_name "$session_content")"
|
|
605
|
+
echo "$formatted_name"
|
|
606
|
+
else
|
|
607
|
+
# Check for old format without repo hash: worktreename_YYYYMMDD_HHMMSS_readable_name
|
|
608
|
+
if [[ "$session_name" =~ ^[^_]+_([0-9]{8})_([0-9]{6})_(.+)$ ]]; then
|
|
609
|
+
local date_part="${BASH_REMATCH[1]}" # YYYYMMDD
|
|
610
|
+
local time_part="${BASH_REMATCH[2]}" # HHMMSS
|
|
611
|
+
local readable_name="${BASH_REMATCH[3]}" # readable_name
|
|
612
|
+
|
|
613
|
+
# Parse date: YYYYMMDD -> Jul 21
|
|
614
|
+
local year="${date_part:0:4}"
|
|
615
|
+
local month="${date_part:4:2}"
|
|
616
|
+
local day="${date_part:6:2}"
|
|
617
|
+
|
|
618
|
+
# Parse time: HHMMSS -> 12:30am
|
|
619
|
+
local hour="${time_part:0:2}"
|
|
620
|
+
local minute="${time_part:2:2}"
|
|
621
|
+
|
|
622
|
+
# Convert to 12-hour format
|
|
623
|
+
local ampm="am"
|
|
624
|
+
local display_hour="$hour"
|
|
625
|
+
if [[ "$hour" -ge 12 ]]; then
|
|
626
|
+
ampm="pm"
|
|
627
|
+
if [[ "$hour" -gt 12 ]]; then
|
|
628
|
+
display_hour=$((hour - 12))
|
|
629
|
+
fi
|
|
630
|
+
fi
|
|
631
|
+
if [[ "$hour" == "00" ]]; then
|
|
632
|
+
display_hour="12"
|
|
633
|
+
fi
|
|
634
|
+
|
|
635
|
+
# Remove leading zero from hour
|
|
636
|
+
display_hour="${display_hour#0}"
|
|
637
|
+
|
|
638
|
+
# Convert month number to name
|
|
639
|
+
local month_names=("" "Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec")
|
|
640
|
+
local month_name="${month_names[${month#0}]}"
|
|
641
|
+
|
|
642
|
+
# Remove leading zero from day
|
|
643
|
+
local display_day="${day#0}"
|
|
644
|
+
|
|
645
|
+
# Format the readable name with proper formatting
|
|
646
|
+
local formatted_name="$(format_session_display_name "$readable_name")"
|
|
647
|
+
|
|
648
|
+
echo "${formatted_name} (${month_name} ${display_day} ${display_hour}:${minute}${ampm})"
|
|
649
|
+
else
|
|
650
|
+
# Fallback for sessions that don't match either format
|
|
651
|
+
echo "$session_name"
|
|
652
|
+
fi
|
|
653
|
+
fi
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
# Get active pane id for a session (best effort)
|
|
657
|
+
# Usage: tmux_get_active_pane <session_name>
|
|
658
|
+
tmux_get_active_pane() {
|
|
659
|
+
local s="$1"
|
|
660
|
+
tmux_available || return 1
|
|
661
|
+
|
|
662
|
+
# Find active window id
|
|
663
|
+
local win
|
|
664
|
+
win="$(tmux list-windows -t "$s" -F '#{window_active} #{window_id}' 2>/dev/null | awk '$1==1{print $2; exit}')" || true
|
|
665
|
+
[[ -z "$win" ]] && return 1
|
|
666
|
+
|
|
667
|
+
# Find active pane id within that window
|
|
668
|
+
tmux list-panes -t "$win" -F '#{pane_active} #{pane_id}' 2>/dev/null | awk '$1==1{print $2; exit}' || true
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
# Capture enhanced session preview showing current terminal view (~15 lines)
|
|
672
|
+
# Usage: tmux_session_preview <session_name>
|
|
673
|
+
tmux_session_preview() {
|
|
674
|
+
local s="$1"
|
|
675
|
+
tmux_available || { echo "(tmux not installed)"; return 0; }
|
|
676
|
+
|
|
677
|
+
local pane
|
|
678
|
+
pane="$(tmux_get_active_pane "$s" || true)"
|
|
679
|
+
if [[ -z "$pane" ]]; then
|
|
680
|
+
echo "(no active pane found)"
|
|
681
|
+
return 0
|
|
682
|
+
fi
|
|
683
|
+
|
|
684
|
+
# Capture from the bottom of the pane with ANSI escape sequences preserved
|
|
685
|
+
# -e flag preserves escape sequences for colors and formatting
|
|
686
|
+
# -S - means start from the current position (bottom)
|
|
687
|
+
# -E - means end at the current position
|
|
688
|
+
# This captures the visible content from the bottom up
|
|
689
|
+
local txt
|
|
690
|
+
txt="$(tmux capture-pane -e -p -S - -E - -t "$pane" 2>/dev/null)"
|
|
691
|
+
|
|
692
|
+
if [[ -z "$txt" ]]; then
|
|
693
|
+
echo "(no output yet)"
|
|
694
|
+
return 0
|
|
695
|
+
fi
|
|
696
|
+
|
|
697
|
+
# Also get the terminal type for color mode detection
|
|
698
|
+
local term_info
|
|
699
|
+
term_info="$(tmux show-environment -t "$s" TERM 2>/dev/null | cut -d= -f2 || echo "unknown")"
|
|
700
|
+
|
|
701
|
+
# Check if true color is supported in this session
|
|
702
|
+
local has_rgb="false"
|
|
703
|
+
if tmux show-options -t "$s" -s terminal-overrides 2>/dev/null | grep -q "RGB"; then
|
|
704
|
+
has_rgb="true"
|
|
705
|
+
fi
|
|
706
|
+
|
|
707
|
+
# For ANSI-preserved preview with color mode info
|
|
708
|
+
# Add markers for the Rust parser to detect color capabilities
|
|
709
|
+
if [[ "$has_rgb" == "true" || "$term_info" == *"direct"* || "$term_info" == *"truecolor"* ]]; then
|
|
710
|
+
echo "<<<COLORMODE:RGB>>>"
|
|
711
|
+
elif [[ "$term_info" == *"256color"* ]]; then
|
|
712
|
+
echo "<<<COLORMODE:256>>>"
|
|
713
|
+
else
|
|
714
|
+
echo "<<<COLORMODE:16>>>"
|
|
715
|
+
fi
|
|
716
|
+
echo "$txt"
|
|
717
|
+
|
|
718
|
+
# If no content after processing, show placeholder
|
|
719
|
+
if [[ -z "$txt" ]]; then
|
|
720
|
+
echo "(session active, no visible output)"
|
|
721
|
+
return 0
|
|
722
|
+
fi
|
|
723
|
+
|
|
724
|
+
echo "$txt"
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
# --------------------------- Advanced Operations ----------------------------
|
|
728
|
+
|
|
729
|
+
# Send keys to a session and press Enter
|
|
730
|
+
# Usage: tmux_send_keys <session_name> <command...>
|
|
731
|
+
tmux_send_keys() {
|
|
732
|
+
local session_name="$1"; shift || true
|
|
733
|
+
local command_line="$*"
|
|
734
|
+
if ! tmux_available; then
|
|
735
|
+
err "tmux not installed"
|
|
736
|
+
return 1
|
|
737
|
+
fi
|
|
738
|
+
if [[ -z "$session_name" || -z "$command_line" ]]; then
|
|
739
|
+
err "tmux_send_keys: session and command required"
|
|
740
|
+
return 1
|
|
741
|
+
fi
|
|
742
|
+
tmux send-keys -t "$session_name" -l -- "$command_line" 2>/dev/null || return 1
|
|
743
|
+
tmux send-keys -t "$session_name" C-m 2>/dev/null || return 1
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
# Load .env file if it exists
|
|
747
|
+
# Usage: tmux_load_env_file [env_file_path]
|
|
748
|
+
tmux_load_env_file() {
|
|
749
|
+
local env_file="${1:-$PWD/.env}"
|
|
750
|
+
if [[ -f "$env_file" ]]; then
|
|
751
|
+
# Source the .env file, but only export ANTHROPIC_API_KEY
|
|
752
|
+
set -a # Auto-export variables
|
|
753
|
+
source "$env_file"
|
|
754
|
+
set +a # Turn off auto-export
|
|
755
|
+
fi
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
# Load Anthropic API key from config file or fallback to .env files
|
|
759
|
+
tmux_load_anthropic_api_key() {
|
|
760
|
+
# First try to load from ~/.orchestra/config.json
|
|
761
|
+
local config_file="$HOME/.orchestra/config.json"
|
|
762
|
+
if [[ -f "$config_file" ]] && command -v jq >/dev/null 2>&1; then
|
|
763
|
+
local api_key
|
|
764
|
+
api_key="$(jq -r '.anthropic_api_key // empty' "$config_file" 2>/dev/null)"
|
|
765
|
+
if [[ -n "$api_key" ]] && [[ "$api_key" != "null" ]]; then
|
|
766
|
+
export ANTHROPIC_API_KEY="$api_key"
|
|
767
|
+
return 0
|
|
768
|
+
fi
|
|
769
|
+
fi
|
|
770
|
+
|
|
771
|
+
# Fallback to .env file loading (existing logic)
|
|
772
|
+
tmux_load_env_file "$PWD/.env"
|
|
773
|
+
|
|
774
|
+
# If still no API key, try repo root
|
|
775
|
+
if [[ -z "${ANTHROPIC_API_KEY-}" ]]; then
|
|
776
|
+
local root
|
|
777
|
+
root="$(repo_root)"
|
|
778
|
+
if [[ -n "$root" ]]; then
|
|
779
|
+
tmux_load_env_file "$root/.env"
|
|
780
|
+
fi
|
|
781
|
+
fi
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
# Generate a descriptive name for a tmux session using AI
|
|
785
|
+
# Usage: tmux_generate_ai_session_name <session_name>
|
|
786
|
+
tmux_generate_ai_session_name() {
|
|
787
|
+
local session="$1"
|
|
788
|
+
|
|
789
|
+
# Load API key from config file or fallback to .env files
|
|
790
|
+
tmux_load_anthropic_api_key
|
|
791
|
+
|
|
792
|
+
# Check if ANTHROPIC_API_KEY is set
|
|
793
|
+
if [[ -z "${ANTHROPIC_API_KEY-}" ]]; then
|
|
794
|
+
err "ANTHROPIC_API_KEY not found in config file or .env file"
|
|
795
|
+
return 1
|
|
796
|
+
fi
|
|
797
|
+
|
|
798
|
+
# Capture the tmux pane content (last 200 lines for better context)
|
|
799
|
+
local content
|
|
800
|
+
content="$(tmux capture-pane -t "$session" -p -S -200 2>/dev/null)" || {
|
|
801
|
+
err "Failed to capture tmux pane content"
|
|
802
|
+
return 1
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
local pane_cmd
|
|
806
|
+
pane_cmd="$(tmux display-message -t "$session" -p '#{pane_current_command}' 2>/dev/null || echo "")"
|
|
807
|
+
|
|
808
|
+
local history_temp=""
|
|
809
|
+
local history_arg=""
|
|
810
|
+
local session_key
|
|
811
|
+
session_key="$(_orchestra_history_key "$session")"
|
|
812
|
+
if [[ -n "$session_key" ]]; then
|
|
813
|
+
local history_dir="${ORCHESTRA_HISTORY_DIR:-$HOME/.orchestra/history}"
|
|
814
|
+
local history_path="$history_dir/$session_key.log"
|
|
815
|
+
if [[ -f "$history_path" ]]; then
|
|
816
|
+
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
817
|
+
history_temp=$(mktemp -t gw_hist)
|
|
818
|
+
else
|
|
819
|
+
history_temp=$(mktemp)
|
|
820
|
+
fi
|
|
821
|
+
tail -n 50 "$history_path" > "$history_temp"
|
|
822
|
+
history_arg="$history_temp"
|
|
823
|
+
fi
|
|
824
|
+
fi
|
|
825
|
+
|
|
826
|
+
# If content is empty or too short, return error
|
|
827
|
+
if [[ ${#content} -lt 10 ]]; then
|
|
828
|
+
err "Not enough content to analyze"
|
|
829
|
+
return 1
|
|
830
|
+
fi
|
|
831
|
+
|
|
832
|
+
# Truncate content if too long (to stay within token limits)
|
|
833
|
+
if [[ ${#content} -gt 8000 ]]; then
|
|
834
|
+
content="${content: -8000}"
|
|
835
|
+
fi
|
|
836
|
+
|
|
837
|
+
# Create a temporary file for the content to avoid escaping issues
|
|
838
|
+
# mktemp works differently on macOS vs Linux
|
|
839
|
+
local temp_file
|
|
840
|
+
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
841
|
+
temp_file=$(mktemp -t gw_tmp)
|
|
842
|
+
else
|
|
843
|
+
temp_file=$(mktemp)
|
|
844
|
+
fi
|
|
845
|
+
printf '%s' "$content" > "$temp_file"
|
|
846
|
+
|
|
847
|
+
# Prepare the API request using Python for proper JSON encoding
|
|
848
|
+
local request_body
|
|
849
|
+
if have_cmd python3; then
|
|
850
|
+
# Use Python to safely build the request body and extract typed commands from the capture
|
|
851
|
+
request_body=$(
|
|
852
|
+
python3 - "$temp_file" "$history_arg" 2>/dev/null <<'PYCODE'
|
|
853
|
+
import json
|
|
854
|
+
import re
|
|
855
|
+
import sys
|
|
856
|
+
from pathlib import Path
|
|
857
|
+
|
|
858
|
+
temp_path = Path(sys.argv[1])
|
|
859
|
+
content = temp_path.read_text()
|
|
860
|
+
|
|
861
|
+
lines = content.splitlines()
|
|
862
|
+
command_candidates = [] # Commands captured with high confidence
|
|
863
|
+
fallback_candidates = [] # All prompt-like lines (used if we fail to classify)
|
|
864
|
+
|
|
865
|
+
history_commands = []
|
|
866
|
+
if len(sys.argv) > 2 and sys.argv[2]:
|
|
867
|
+
hist_path = Path(sys.argv[2])
|
|
868
|
+
if hist_path.exists():
|
|
869
|
+
for line in hist_path.read_text().splitlines():
|
|
870
|
+
line = line.strip()
|
|
871
|
+
if not line:
|
|
872
|
+
continue
|
|
873
|
+
if "\t" in line:
|
|
874
|
+
history_commands.append(line.split("\t", 1)[1])
|
|
875
|
+
else:
|
|
876
|
+
history_commands.append(line)
|
|
877
|
+
history_commands = history_commands[-50:]
|
|
878
|
+
|
|
879
|
+
prompt_pattern = re.compile(r"^[A-Za-z0-9_.@~/-]+$")
|
|
880
|
+
disallowed_prompts = {
|
|
881
|
+
"warning",
|
|
882
|
+
"error",
|
|
883
|
+
"fatal",
|
|
884
|
+
"hint",
|
|
885
|
+
"note",
|
|
886
|
+
"usage",
|
|
887
|
+
"info",
|
|
888
|
+
"debug",
|
|
889
|
+
"trace",
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
disallowed_first_tokens = {
|
|
893
|
+
"warning",
|
|
894
|
+
"error",
|
|
895
|
+
"fatal",
|
|
896
|
+
"hint",
|
|
897
|
+
"note",
|
|
898
|
+
"usage",
|
|
899
|
+
"not",
|
|
900
|
+
"see",
|
|
901
|
+
"for",
|
|
902
|
+
"from",
|
|
903
|
+
"with",
|
|
904
|
+
"and",
|
|
905
|
+
"or",
|
|
906
|
+
"but",
|
|
907
|
+
"at",
|
|
908
|
+
"to",
|
|
909
|
+
"in",
|
|
910
|
+
"info",
|
|
911
|
+
"debug",
|
|
912
|
+
"trace",
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
common_commands = {
|
|
916
|
+
"git",
|
|
917
|
+
"gh",
|
|
918
|
+
"npm",
|
|
919
|
+
"pnpm",
|
|
920
|
+
"yarn",
|
|
921
|
+
"node",
|
|
922
|
+
"npx",
|
|
923
|
+
"bun",
|
|
924
|
+
"cargo",
|
|
925
|
+
"go",
|
|
926
|
+
"python",
|
|
927
|
+
"python3",
|
|
928
|
+
"pip",
|
|
929
|
+
"pip3",
|
|
930
|
+
"poetry",
|
|
931
|
+
"pipenv",
|
|
932
|
+
"pytest",
|
|
933
|
+
"uvicorn",
|
|
934
|
+
"gunicorn",
|
|
935
|
+
"flask",
|
|
936
|
+
"django-admin",
|
|
937
|
+
"rails",
|
|
938
|
+
"bundle",
|
|
939
|
+
"rake",
|
|
940
|
+
"mix",
|
|
941
|
+
"make",
|
|
942
|
+
"cmake",
|
|
943
|
+
"gradle",
|
|
944
|
+
"mvn",
|
|
945
|
+
"ant",
|
|
946
|
+
"docker",
|
|
947
|
+
"docker-compose",
|
|
948
|
+
"kubectl",
|
|
949
|
+
"helm",
|
|
950
|
+
"terraform",
|
|
951
|
+
"ansible",
|
|
952
|
+
"ssh",
|
|
953
|
+
"scp",
|
|
954
|
+
"rsync",
|
|
955
|
+
"sftp",
|
|
956
|
+
"psql",
|
|
957
|
+
"mysql",
|
|
958
|
+
"mongo",
|
|
959
|
+
"redis-cli",
|
|
960
|
+
"sqlite3",
|
|
961
|
+
"composer",
|
|
962
|
+
"php",
|
|
963
|
+
"ruby",
|
|
964
|
+
"java",
|
|
965
|
+
"javac",
|
|
966
|
+
"deno",
|
|
967
|
+
"dotnet",
|
|
968
|
+
"msbuild",
|
|
969
|
+
"tsc",
|
|
970
|
+
"nx",
|
|
971
|
+
"lerna",
|
|
972
|
+
"eslint",
|
|
973
|
+
"prettier",
|
|
974
|
+
"ls",
|
|
975
|
+
"cd",
|
|
976
|
+
"pwd",
|
|
977
|
+
"cat",
|
|
978
|
+
"tail",
|
|
979
|
+
"head",
|
|
980
|
+
"less",
|
|
981
|
+
"more",
|
|
982
|
+
"grep",
|
|
983
|
+
"rg",
|
|
984
|
+
"fd",
|
|
985
|
+
"find",
|
|
986
|
+
"watch",
|
|
987
|
+
"code",
|
|
988
|
+
"open",
|
|
989
|
+
"vim",
|
|
990
|
+
"nvim",
|
|
991
|
+
"tmux",
|
|
992
|
+
"htop",
|
|
993
|
+
"top",
|
|
994
|
+
"brew",
|
|
995
|
+
"tox",
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
allowed_prefixes = (
|
|
999
|
+
"./",
|
|
1000
|
+
"../",
|
|
1001
|
+
"~/",
|
|
1002
|
+
"bin/",
|
|
1003
|
+
"sbin/",
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def clean_tokens(tokens):
|
|
1008
|
+
while tokens and tokens[0] in {"$", "#", "%"}:
|
|
1009
|
+
tokens = tokens[1:]
|
|
1010
|
+
return tokens
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
for raw_line in lines:
|
|
1014
|
+
stripped = raw_line.strip()
|
|
1015
|
+
if not stripped or ":" not in stripped:
|
|
1016
|
+
continue
|
|
1017
|
+
|
|
1018
|
+
prompt_part, command_part = stripped.split(":", 1)
|
|
1019
|
+
prompt_part = prompt_part.strip().rstrip("#$%")
|
|
1020
|
+
command_part = command_part.strip()
|
|
1021
|
+
|
|
1022
|
+
if not prompt_part or not command_part:
|
|
1023
|
+
continue
|
|
1024
|
+
|
|
1025
|
+
if not prompt_pattern.match(prompt_part):
|
|
1026
|
+
continue
|
|
1027
|
+
|
|
1028
|
+
prompt_lower = prompt_part.lower()
|
|
1029
|
+
if prompt_lower in disallowed_prompts:
|
|
1030
|
+
continue
|
|
1031
|
+
|
|
1032
|
+
tokens = clean_tokens(command_part.split())
|
|
1033
|
+
if not tokens:
|
|
1034
|
+
continue
|
|
1035
|
+
|
|
1036
|
+
first_token = tokens[0].lower()
|
|
1037
|
+
if first_token == "sudo" and len(tokens) > 1:
|
|
1038
|
+
first_token = tokens[1].lower()
|
|
1039
|
+
|
|
1040
|
+
if first_token in disallowed_first_tokens:
|
|
1041
|
+
continue
|
|
1042
|
+
|
|
1043
|
+
normalized_command = " ".join(tokens)
|
|
1044
|
+
formatted_line = f"{prompt_part}:{normalized_command}"
|
|
1045
|
+
fallback_candidates.append(formatted_line)
|
|
1046
|
+
|
|
1047
|
+
allowed = (
|
|
1048
|
+
first_token in common_commands
|
|
1049
|
+
or normalized_command.startswith(allowed_prefixes)
|
|
1050
|
+
or any(
|
|
1051
|
+
first_token.startswith(prefix)
|
|
1052
|
+
for prefix in (
|
|
1053
|
+
"git",
|
|
1054
|
+
"npm",
|
|
1055
|
+
"pnpm",
|
|
1056
|
+
"yarn",
|
|
1057
|
+
"node",
|
|
1058
|
+
"npx",
|
|
1059
|
+
"bun",
|
|
1060
|
+
"cargo",
|
|
1061
|
+
"python",
|
|
1062
|
+
"pip",
|
|
1063
|
+
"poetry",
|
|
1064
|
+
"pytest",
|
|
1065
|
+
"uvicorn",
|
|
1066
|
+
"docker",
|
|
1067
|
+
"kubectl",
|
|
1068
|
+
"helm",
|
|
1069
|
+
"terraform",
|
|
1070
|
+
"ansible",
|
|
1071
|
+
"ssh",
|
|
1072
|
+
"scp",
|
|
1073
|
+
"rsync",
|
|
1074
|
+
"rails",
|
|
1075
|
+
"bundle",
|
|
1076
|
+
"rake",
|
|
1077
|
+
"mix",
|
|
1078
|
+
"psql",
|
|
1079
|
+
"mysql",
|
|
1080
|
+
"mongo",
|
|
1081
|
+
"redis",
|
|
1082
|
+
)
|
|
1083
|
+
)
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
if allowed:
|
|
1087
|
+
command_candidates.append(formatted_line)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
if history_commands:
|
|
1091
|
+
command_candidates = history_commands
|
|
1092
|
+
elif not command_candidates:
|
|
1093
|
+
command_candidates = fallback_candidates
|
|
1094
|
+
|
|
1095
|
+
extracted_commands = command_candidates
|
|
1096
|
+
history_lines = [f"{idx + 1}. {line}" for idx, line in enumerate(extracted_commands)]
|
|
1097
|
+
command_history = "\n".join(history_lines) if history_lines else "None detected."
|
|
1098
|
+
last_command_line = extracted_commands[-1] if extracted_commands else ""
|
|
1099
|
+
commands_detected = bool(extracted_commands)
|
|
1100
|
+
|
|
1101
|
+
# Capture the first 10 lines of output as fallback context (joined by newline)
|
|
1102
|
+
first_ten_lines = "\n".join(lines[:10]) if lines else ""
|
|
1103
|
+
|
|
1104
|
+
prefix_guidance = """Prefix selection rules based on the most recent command:
|
|
1105
|
+
- git_* : git or gh commands, or any git-related activity
|
|
1106
|
+
- opencode_* : Claude Code, OpenCode, Cursor, or similar AI coding tools
|
|
1107
|
+
- running_* : Commands that start dev servers (npm/yarn/pnpm run dev, uvicorn, next dev, etc.)
|
|
1108
|
+
- build_* : Build/compile steps (npm/yarn/pnpm run build, cargo build, make, webpack, etc.)
|
|
1109
|
+
- test_* : Unit/integration tests (npm test, pytest, jest, go test, cargo test, etc.)
|
|
1110
|
+
- docker_* : Docker or container tooling (docker, docker-compose, kubectl, helm)
|
|
1111
|
+
- ssh_* : SSH, scp, rsync, or remote shell connections
|
|
1112
|
+
- db_* : Database work (psql, mysql, mongo, redis-cli, migrations)
|
|
1113
|
+
- debug_* : Explicit debugging or error triage commands
|
|
1114
|
+
- deploy_* : Deployment or release related commands
|
|
1115
|
+
- Otherwise derive a concise descriptive prefix from the command context.
|
|
1116
|
+
When multiple categories match, prioritize git_ > docker_ > running_ > build_ > test_ > deploy_ > debug_ > db_ > ssh_ > opencode_."""
|
|
1117
|
+
|
|
1118
|
+
prompt = f"""Analyze this terminal session and create a descriptive tmux session name based on what the user actually did.
|
|
1119
|
+
|
|
1120
|
+
Terminal output (last capture):
|
|
1121
|
+
{content}
|
|
1122
|
+
|
|
1123
|
+
Extracted command history (oldest to newest):
|
|
1124
|
+
{command_history}
|
|
1125
|
+
|
|
1126
|
+
Most recent command (anchor for app prefix and summary):
|
|
1127
|
+
{last_command_line if last_command_line else 'None detected'}
|
|
1128
|
+
|
|
1129
|
+
Focus: use the most recent command to determine the application prefix and primary activity description. Map it according to these rules:
|
|
1130
|
+
{prefix_guidance}
|
|
1131
|
+
|
|
1132
|
+
{'No clear prompt/command lines were detected. Rely on the terminal output context.' if not commands_detected else ''}
|
|
1133
|
+
|
|
1134
|
+
{'First 10 lines of terminal output (useful for identifying running app/service):\n' + first_ten_lines if not commands_detected and first_ten_lines else ''}
|
|
1135
|
+
|
|
1136
|
+
If no commands were detected, fall back to terminal output analysis but describe the main activity in the name. Identify the dominant application or process implied by the output (e.g., web server, test runner, build tool).
|
|
1137
|
+
|
|
1138
|
+
Instructions:
|
|
1139
|
+
1. Produce a session name using lowercase letters, numbers, and underscores only.
|
|
1140
|
+
2. Maximum length: 100 characters.
|
|
1141
|
+
3. Include an app/activity prefix derived from the most recent command (see rules above).
|
|
1142
|
+
4. Describe the concrete task or state in a short phrase after the prefix.
|
|
1143
|
+
5. If git activity is visible anywhere, the prefix MUST be git_.
|
|
1144
|
+
6. Respond with ONLY the final session name, nothing else."""
|
|
1145
|
+
|
|
1146
|
+
request = {
|
|
1147
|
+
"model": "claude-3-5-haiku-latest",
|
|
1148
|
+
"max_tokens": 100,
|
|
1149
|
+
"messages": [
|
|
1150
|
+
{
|
|
1151
|
+
"role": "user",
|
|
1152
|
+
"content": prompt
|
|
1153
|
+
}
|
|
1154
|
+
]
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
print(json.dumps(request))
|
|
1158
|
+
PYCODE
|
|
1159
|
+
)
|
|
1160
|
+
else
|
|
1161
|
+
# Fallback: base64 encode the content to avoid escaping issues
|
|
1162
|
+
local encoded_content=$(base64 < "$temp_file" | tr -d '\n')
|
|
1163
|
+
request_body=$(cat <<EOF
|
|
1164
|
+
{
|
|
1165
|
+
"model": "claude-3-5-haiku-latest",
|
|
1166
|
+
"max_tokens": 100,
|
|
1167
|
+
"messages": [
|
|
1168
|
+
{
|
|
1169
|
+
"role": "user",
|
|
1170
|
+
"content": "I'll send you base64-encoded terminal output. Please decode it, analyze it, and create a descriptive name for this tmux session.\n\nIMPORTANT: Ignore any window titles or metadata. Focus ONLY on the actual terminal content.\n\nCRITICAL: Check for Git patterns FIRST:\n- Git commands: git status, diff, add, commit, push, pull, checkout, branch, merge, rebase, log, stash\n- Git output: 'On branch', 'Changes not staged', 'modified:', 'new file:', diff output (+/-/@@), commit hashes\n- If ANY Git patterns found, use 'git_' prefix (highest priority)\n\nOther prefixes:\n- opencode_: AI coding tools (Claude Code, OpenCode, Cursor)\n- running_: Dev servers (npm run dev, yarn dev)\n- ssh_: SSH connections\n- docker_: Docker/container operations\n- build_: Build processes\n- test_: Testing\n- db_: Database operations\n\nUse underscores only. Max 100 chars total.\n\nExamples:\n- git_reviewing_changes_before_commit\n- git_resolving_merge_conflicts\n- opencode_fixing_auth_bug\n- running_nextjs_dev_server\n\nBase64 terminal output:\n${encoded_content}\n\nRespond with ONLY the session name, nothing else."
|
|
1171
|
+
}
|
|
1172
|
+
]
|
|
1173
|
+
}
|
|
1174
|
+
EOF
|
|
1175
|
+
)
|
|
1176
|
+
fi
|
|
1177
|
+
|
|
1178
|
+
# Clean up temp file
|
|
1179
|
+
rm -f "$temp_file"
|
|
1180
|
+
if [[ -n "$history_temp" ]]; then
|
|
1181
|
+
rm -f "$history_temp"
|
|
1182
|
+
fi
|
|
1183
|
+
|
|
1184
|
+
# Make the API call
|
|
1185
|
+
local response
|
|
1186
|
+
response=$(curl -s -X POST https://api.anthropic.com/v1/messages \
|
|
1187
|
+
--max-time 20 \
|
|
1188
|
+
-H "Content-Type: application/json" \
|
|
1189
|
+
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
|
1190
|
+
-H "anthropic-version: 2023-06-01" \
|
|
1191
|
+
-d "$request_body" 2>&1) || {
|
|
1192
|
+
err "Failed to call Anthropic API: $response"
|
|
1193
|
+
return 1
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
# Check if response contains an error
|
|
1197
|
+
if echo "$response" | grep -q '"error"'; then
|
|
1198
|
+
local error_msg=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('error', {}).get('message', 'Unknown error'))" 2>/dev/null || echo "API error")
|
|
1199
|
+
err "API error: $error_msg"
|
|
1200
|
+
return 1
|
|
1201
|
+
fi
|
|
1202
|
+
|
|
1203
|
+
# Extract the content from the response
|
|
1204
|
+
local new_name
|
|
1205
|
+
new_name=$(echo "$response" | python3 -c "import sys, json; data = json.load(sys.stdin); print(data.get('content', [{}])[0].get('text', ''))" 2>/dev/null) || {
|
|
1206
|
+
# Fallback to grep method if python fails
|
|
1207
|
+
new_name=$(echo "$response" | grep -o '"text":"[^"]*"' | head -1 | sed 's/"text":"\([^"]*\)"/\1/')
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
# Clean up the name (remove any spaces, special chars except underscore and dash)
|
|
1211
|
+
new_name=$(echo "$new_name" | tr ' ' '_' | sed 's/[^a-zA-Z0-9_-]//g' | cut -c1-100)
|
|
1212
|
+
|
|
1213
|
+
# Derive an override prefix from the active pane command (if available)
|
|
1214
|
+
local override_prefix=""
|
|
1215
|
+
if [[ -n "$pane_cmd" ]]; then
|
|
1216
|
+
override_prefix="${pane_cmd##*/}"
|
|
1217
|
+
override_prefix="${override_prefix%% *}"
|
|
1218
|
+
override_prefix=$(echo "$override_prefix" | tr '[:upper:]' '[:lower:]')
|
|
1219
|
+
override_prefix=$(echo "$override_prefix" | tr -c 'a-z0-9' '_')
|
|
1220
|
+
override_prefix="${override_prefix//__/_}"
|
|
1221
|
+
override_prefix="${override_prefix##_}"
|
|
1222
|
+
override_prefix="${override_prefix%%_}"
|
|
1223
|
+
fi
|
|
1224
|
+
|
|
1225
|
+
case "$override_prefix" in
|
|
1226
|
+
""|"bash"|"zsh"|"sh"|"fish"|"tmux"|"login"|"sudo"|"man"|"less"|"more"|"cat"|"tail"|"watch")
|
|
1227
|
+
override_prefix=""
|
|
1228
|
+
;;
|
|
1229
|
+
esac
|
|
1230
|
+
|
|
1231
|
+
if [[ -n "$override_prefix" ]]; then
|
|
1232
|
+
local current_prefix rest
|
|
1233
|
+
current_prefix="${new_name%%_*}"
|
|
1234
|
+
if [[ "$new_name" == *_* ]]; then
|
|
1235
|
+
rest="${new_name#*_}"
|
|
1236
|
+
else
|
|
1237
|
+
rest="$new_name"
|
|
1238
|
+
fi
|
|
1239
|
+
if [[ -z "$rest" || "$rest" == "$override_prefix" ]]; then
|
|
1240
|
+
rest="session_activity"
|
|
1241
|
+
fi
|
|
1242
|
+
if [[ "$current_prefix" != "$override_prefix" ]]; then
|
|
1243
|
+
new_name="${override_prefix}_${rest}"
|
|
1244
|
+
fi
|
|
1245
|
+
fi
|
|
1246
|
+
|
|
1247
|
+
new_name=$(echo "$new_name" | tr ' ' '_' | sed 's/[^a-zA-Z0-9_-]//g' | cut -c1-100)
|
|
1248
|
+
|
|
1249
|
+
if [[ -z "$new_name" ]]; then
|
|
1250
|
+
err "Failed to generate name from AI response"
|
|
1251
|
+
return 1
|
|
1252
|
+
fi
|
|
1253
|
+
|
|
1254
|
+
echo "$new_name"
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
# Display tmux message
|
|
1258
|
+
# Usage: tmux_display_message <message> [duration_ms]
|
|
1259
|
+
tmux_display_message() {
|
|
1260
|
+
local message="$1"
|
|
1261
|
+
local duration="${2:-3000}"
|
|
1262
|
+
|
|
1263
|
+
if tmux_available && tmux_inside_session; then
|
|
1264
|
+
tmux display-message -d "$duration" "$message"
|
|
1265
|
+
fi
|
|
1266
|
+
}
|