@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.
@@ -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
+ }