@humanu/orchestra 0.5.77 → 0.5.79

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.
Files changed (84) hide show
  1. package/bin/{gw.js → orchestra-cli.js} +1 -1
  2. package/bin/orchestra.js +2 -2
  3. package/install.js +41 -42
  4. package/package.json +2 -3
  5. package/resources/api/git.sh +4 -444
  6. package/resources/api/tmux.sh +4 -2804
  7. package/resources/prebuilt/linux-x64/{gw-env-copy → env-copy} +0 -0
  8. package/resources/prebuilt/linux-x64/orchestra +0 -0
  9. package/resources/prebuilt/macos-arm64/{gw-env-copy → env-copy} +0 -0
  10. package/resources/prebuilt/macos-arm64/orchestra +0 -0
  11. package/resources/prebuilt/macos-intel/{gw-env-copy → env-copy} +0 -0
  12. package/resources/prebuilt/macos-intel/orchestra +0 -0
  13. package/resources/scripts/{gw.sh → orchestra-cli.sh} +14 -14
  14. package/resources/scripts/orchestra-local.sh +6 -6
  15. package/resources/scripts/{gwr.sh → orchestra.sh} +11 -55
  16. package/resources/scripts/{shell/bridge → server/services}/ai.sh +4 -4
  17. package/resources/scripts/{gw-bridge.sh → server/services/dispatch.sh} +62 -59
  18. package/resources/scripts/server/services/git/api.sh +447 -0
  19. package/resources/scripts/{shell/git/bridge_check_branch.sh → server/services/git/check_branch_api.sh} +1 -1
  20. package/resources/scripts/{shell/git/bridge_create_worktree.sh → server/services/git/create_worktree_api.sh} +3 -3
  21. package/resources/scripts/{shell/git/bridge_create_worktree_from_existing.sh → server/services/git/create_worktree_from_existing_api.sh} +2 -2
  22. package/resources/scripts/{shell/git/bridge_create_worktree_from_remote.sh → server/services/git/create_worktree_from_remote_api.sh} +2 -2
  23. package/resources/scripts/{shell/git/bridge_delete_branch_only.sh → server/services/git/delete_branch_only_api.sh} +1 -1
  24. package/resources/scripts/{shell/git/bridge_delete_worktree.sh → server/services/git/delete_worktree_api.sh} +2 -2
  25. package/resources/scripts/{shell/git/bridge_delete_worktree_only.sh → server/services/git/delete_worktree_only_api.sh} +1 -1
  26. package/resources/scripts/{shell/git/bridge_enhanced_git_status.sh → server/services/git/enhanced_git_status_api.sh} +3 -3
  27. package/resources/scripts/{shell/git/bridge_git_status.sh → server/services/git/git_status_api.sh} +2 -2
  28. package/resources/scripts/{shell/git/bridge_list_worktrees.sh → server/services/git/list_worktrees_api.sh} +2 -2
  29. package/resources/scripts/server/services/git/merge_api.sh +12 -0
  30. package/resources/scripts/{shell/git/bridge_merge_from_primary.sh → server/services/git/merge_from_primary_api.sh} +1 -1
  31. package/resources/scripts/{shell/git/bridge_merge_into_primary.sh → server/services/git/merge_into_primary_api.sh} +1 -1
  32. package/resources/scripts/{shell/git/bridge_primary_branch.sh → server/services/git/primary_branch_api.sh} +1 -1
  33. package/resources/scripts/{shell/git/bridge_rebase_from_primary.sh → server/services/git/rebase_from_primary_api.sh} +1 -1
  34. package/resources/scripts/server/services/git/repo_api.sh +12 -0
  35. package/resources/scripts/{shell/git/bridge_repo_info.sh → server/services/git/repo_info_api.sh} +1 -1
  36. package/resources/scripts/{shell/git/bridge_squash_into_primary.sh → server/services/git/squash_into_primary_api.sh} +1 -1
  37. package/resources/scripts/{shell/git/bridge_switch_worktree.sh → server/services/git/switch_worktree_api.sh} +2 -2
  38. package/resources/scripts/server/services/git/worktree_api.sh +17 -0
  39. package/resources/scripts/{shell/bridge/utils.sh → server/services/json.sh} +23 -23
  40. package/resources/scripts/{shell/bridge → server/services/session}/tmux.sh +14 -14
  41. package/resources/scripts/server/session/tmux_api.sh +2812 -0
  42. package/resources/scripts/services.sh +6 -0
  43. package/resources/scripts/shell/AGENTS.md +63 -74
  44. package/resources/scripts/shell/build/dependencies.sh +33 -0
  45. package/resources/scripts/shell/build/install.sh +7 -0
  46. package/resources/scripts/shell/build/load.sh +10 -0
  47. package/resources/scripts/shell/build/logging.sh +6 -0
  48. package/resources/scripts/shell/build/rust.sh +18 -0
  49. package/resources/scripts/shell/build/services.sh +17 -0
  50. package/resources/scripts/shell/build/{build_usage.sh → usage.sh} +6 -6
  51. package/resources/scripts/shell/cli_load.sh +9 -0
  52. package/resources/scripts/shell/{gw_env_copy.sh → env_copy.sh} +11 -11
  53. package/resources/scripts/shell/env_copy_command.sh +2 -2
  54. package/resources/scripts/shell/git/checkout_worktree.sh +4 -4
  55. package/resources/scripts/shell/git/create_worktree.sh +2 -2
  56. package/resources/scripts/shell/git/delete_worktree.sh +1 -1
  57. package/resources/scripts/shell/git/merge.sh +1 -1
  58. package/resources/scripts/shell/git/repo.sh +1 -1
  59. package/resources/scripts/shell/git/worktree.sh +1 -1
  60. package/resources/scripts/shell/gwr/check-updates.sh +1 -1
  61. package/resources/scripts/shell/gwr_binary.sh +4 -4
  62. package/resources/scripts/shell/gwr_load.sh +1 -1
  63. package/resources/scripts/shell/gwr_services.sh +10 -0
  64. package/resources/scripts/shell/gwr_usage.sh +10 -10
  65. package/resources/scripts/shell/orchestra-command-hook.sh +15 -15
  66. package/resources/scripts/shell/orchestra-local.sh +6 -6
  67. package/resources/scripts/shell/tmux/new_session_command.sh +1 -1
  68. package/bin/gwr.js +0 -10
  69. package/resources/scripts/shell/build/build_bridge.sh +0 -17
  70. package/resources/scripts/shell/build/build_dependencies.sh +0 -33
  71. package/resources/scripts/shell/build/build_install.sh +0 -7
  72. package/resources/scripts/shell/build/build_load.sh +0 -10
  73. package/resources/scripts/shell/build/build_logging.sh +0 -6
  74. package/resources/scripts/shell/build/build_rust.sh +0 -18
  75. package/resources/scripts/shell/git/bridge_merge.sh +0 -12
  76. package/resources/scripts/shell/git/bridge_repo.sh +0 -12
  77. package/resources/scripts/shell/git/bridge_worktree.sh +0 -17
  78. package/resources/scripts/shell/gw_legacy_wrappers.sh +0 -7
  79. package/resources/scripts/shell/gw_load.sh +0 -10
  80. package/resources/scripts/shell/gwr_bridge.sh +0 -10
  81. /package/resources/scripts/shell/{gw_debug.sh → cli_debug.sh} +0 -0
  82. /package/resources/scripts/shell/{gw_err.sh → cli_err.sh} +0 -0
  83. /package/resources/scripts/shell/{gw_have_cmd.sh → cli_have_cmd.sh} +0 -0
  84. /package/resources/scripts/shell/{gw_info.sh → cli_info.sh} +0 -0
@@ -0,0 +1,2812 @@
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 orchestra-cli.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
+ # Absolute directory that contains this script when sourced.
28
+ _TMUX_API_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
29
+ _ORCHESTRA_ROOT_DIR="$(cd "$_TMUX_API_DIR/../.." && pwd -P)"
30
+
31
+ # Helper: return delimiter
32
+ _tmux_delim() { echo "$ORCHESTRA_SESSION_DELIM"; }
33
+
34
+ # Helper: sanitize tmux session name for filesystem usage
35
+ _orchestra_history_key() {
36
+ local key="$1"
37
+ key="${key//\//_}"
38
+ key=$(echo "$key" | tr '[:space:]' '_')
39
+ key=$(echo "$key" | tr -c '[:alnum:]_-' '_')
40
+ while [[ "$key" == *"__"* ]]; do
41
+ key="${key//__/_}"
42
+ done
43
+ key="${key##_}"
44
+ key="${key%%_}"
45
+ echo "$key"
46
+ }
47
+
48
+ _tmux_normalize_app_from_command() {
49
+ local cmd="$1"
50
+ cmd="${cmd#"${cmd%%[![:space:]]*}"}"
51
+ cmd="${cmd%"${cmd##*[![:space:]]}"}"
52
+ if [[ -z "$cmd" ]]; then
53
+ return
54
+ fi
55
+
56
+ local tokens=()
57
+ read -r -a tokens <<< "$cmd"
58
+ if [[ ${#tokens[@]} -eq 0 ]]; then
59
+ return
60
+ fi
61
+
62
+ local i=0
63
+ while [[ $i -lt ${#tokens[@]} ]]; do
64
+ local t="${tokens[$i]}"
65
+ if [[ "$t" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
66
+ ((i++))
67
+ continue
68
+ fi
69
+ case "$t" in
70
+ sudo|env|command|nohup|time)
71
+ ((i++))
72
+ continue
73
+ ;;
74
+ esac
75
+ break
76
+ done
77
+
78
+ if [[ $i -ge ${#tokens[@]} ]]; then
79
+ return
80
+ fi
81
+
82
+ local base="${tokens[$i]}"
83
+ base="${base##*/}"
84
+ base="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._:-' '_')"
85
+ base="${base//__/_}"
86
+ base="${base##_}"
87
+ base="${base%%_}"
88
+
89
+ case "$base" in
90
+ python3|python3.*)
91
+ base="python"
92
+ ;;
93
+ pip3|pip3.*)
94
+ base="pip"
95
+ ;;
96
+ docker-compose|docker_compose)
97
+ base="docker_compose"
98
+ ;;
99
+ nvim|nvim.app)
100
+ base="nvim"
101
+ ;;
102
+ esac
103
+
104
+ case "$base" in
105
+ ""|bash|zsh|sh|fish|tmux|login|sudo|man|less|more|cat|tail|watch|source|export|set|alias|unalias|history|bindkey)
106
+ return
107
+ ;;
108
+ esac
109
+
110
+ printf '%s' "$base"
111
+ }
112
+
113
+ _tmux_truthy() {
114
+ case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
115
+ 1|yes|true|on)
116
+ return 0
117
+ ;;
118
+ esac
119
+ return 1
120
+ }
121
+
122
+ _tmux_is_known_tui_app() {
123
+ local app
124
+ app="$(_tmux_normalize_app_from_command "$1")"
125
+ case "$app" in
126
+ opencode|claude|vim|nvim|vi|lazygit|gitui|tig|top|htop|btop|k9s|fzf|yazi|ranger|nnn|less|man)
127
+ return 0
128
+ ;;
129
+ esac
130
+ return 1
131
+ }
132
+
133
+ _tmux_is_tui_context() {
134
+ local pane_cmd="$1"
135
+ local alternate_on="$2"
136
+ local mouse_any_flag="$3"
137
+ local pane_mode="$4"
138
+ local window_name="$5"
139
+ local pane_title="$6"
140
+
141
+ if _tmux_truthy "$alternate_on" || _tmux_truthy "$mouse_any_flag"; then
142
+ return 0
143
+ fi
144
+
145
+ if [[ -n "$pane_mode" ]]; then
146
+ return 0
147
+ fi
148
+
149
+ if _tmux_is_known_tui_app "$pane_cmd"; then
150
+ return 0
151
+ fi
152
+
153
+ local combined
154
+ combined="$(printf '%s %s' "$window_name" "$pane_title" | tr '[:upper:]' '[:lower:]')"
155
+ case "$combined" in
156
+ *opencode*|*claude*|*vim*|*nvim*|*lazygit*|*gitui*|*tig*|*k9s*|*fzf*|*yazi*|*ranger*|*nnn*)
157
+ return 0
158
+ ;;
159
+ esac
160
+
161
+ return 1
162
+ }
163
+
164
+ # Helper: absolute path to the command hook script (if present)
165
+ _orchestra_command_hook() {
166
+ local hook=""
167
+
168
+ # 1) Explicit install root set by wrappers (Homebrew/NPM)
169
+ if [[ -n "${GW_ORCHESTRATOR_ROOT-}" ]]; then
170
+ hook="$GW_ORCHESTRATOR_ROOT/shell/orchestra-command-hook.sh"
171
+ if [[ -f "$hook" ]]; then
172
+ echo "$hook"
173
+ return
174
+ fi
175
+ fi
176
+
177
+ # 2) Resolve relative to the server/session implementation location
178
+ hook="$_ORCHESTRA_ROOT_DIR/shell/orchestra-command-hook.sh"
179
+ if [[ -f "$hook" ]]; then
180
+ echo "$hook"
181
+ return
182
+ fi
183
+
184
+ # 3) Fallback to repo root for legacy/dev flows
185
+ local root
186
+ root="$(repo_root)"
187
+ if [[ -n "$root" ]]; then
188
+ hook="$root/shell/orchestra-command-hook.sh"
189
+ if [[ -f "$hook" ]]; then
190
+ echo "$hook"
191
+ return
192
+ fi
193
+ fi
194
+
195
+ echo ""
196
+ }
197
+
198
+ _orchestra_services_script() {
199
+ echo "$_ORCHESTRA_ROOT_DIR/services.sh"
200
+ }
201
+
202
+ # Source the command hook inside a tmux session to enable command history logging
203
+ _tmux_source_command_hook() {
204
+ local session="$1"
205
+ local hook
206
+ hook="$(_orchestra_command_hook)"
207
+ if [[ -z "$hook" ]]; then
208
+ return
209
+ fi
210
+ if ! tmux_session_exists "$session"; then
211
+ return
212
+ fi
213
+ local services
214
+ services="$(_orchestra_services_script)"
215
+ local panes
216
+ panes=$(tmux list-panes -t "$session" -F '#{pane_id} #{pane_current_command}' 2>/dev/null || echo "")
217
+ if [[ -z "$panes" ]]; then
218
+ return
219
+ fi
220
+ while IFS= read -r line; do
221
+ [[ -z "$line" ]] && continue
222
+ local pane_id pane_cmd
223
+ pane_id="${line%% *}"
224
+ pane_cmd="${line#* }"
225
+ case "$pane_cmd" in
226
+ bash|zsh)
227
+ tmux send-keys -t "$pane_id" "export ORCHESTRA_SERVICES_PATH='$services'; . '$hook'" C-m 2>/dev/null || true
228
+ ;;
229
+ *)
230
+ ;;
231
+ esac
232
+ done <<< "$panes"
233
+ }
234
+
235
+ # Helper: orchestra prefix including delimiter
236
+ _tmux_orch_prefix() { printf "orchestra%s" "$( _tmux_delim )"; }
237
+
238
+ _tmux_session_registry_path() {
239
+ if [[ -n "${ORCHESTRA_SESSION_REGISTRY_PATH-}" ]]; then
240
+ printf '%s\n' "$ORCHESTRA_SESSION_REGISTRY_PATH"
241
+ return
242
+ fi
243
+ printf '%s\n' "$HOME/.orchestra/orchestra.sqlite3"
244
+ }
245
+
246
+ _tmux_shared_root_for_path() {
247
+ local path="$1"
248
+ local old_pwd="$PWD"
249
+ local root=""
250
+ if [[ -n "$path" && -d "$path" ]]; then
251
+ cd "$path" 2>/dev/null || return 1
252
+ root="$(git_shared_root 2>/dev/null || git_repo_root 2>/dev/null || true)"
253
+ cd "$old_pwd" 2>/dev/null || true
254
+ fi
255
+ [[ -n "$root" ]] || return 1
256
+ printf '%s\n' "$root"
257
+ }
258
+
259
+ _tmux_branch_for_path() {
260
+ local path="$1"
261
+ local old_pwd="$PWD"
262
+ local branch=""
263
+ if [[ -n "$path" && -d "$path" ]]; then
264
+ cd "$path" 2>/dev/null || return 1
265
+ branch="$(git_current_branch 2>/dev/null || true)"
266
+ cd "$old_pwd" 2>/dev/null || true
267
+ fi
268
+ [[ -n "$branch" ]] || return 1
269
+ printf '%s\n' "$branch"
270
+ }
271
+
272
+ _tmux_registry_upsert_session() {
273
+ local repo_root="$1"
274
+ local branch="$2"
275
+ local worktree_path="$3"
276
+ local tmux_name="$4"
277
+ local db_path
278
+ db_path="$(_tmux_session_registry_path)"
279
+
280
+ [[ -n "$repo_root" && -n "$branch" && -n "$worktree_path" && -n "$tmux_name" ]] || return 1
281
+ have_cmd python3 || return 1
282
+
283
+ python3 - "$db_path" "$repo_root" "$branch" "$worktree_path" "$tmux_name" <<'PY'
284
+ import os
285
+ import sqlite3
286
+ import sys
287
+ import time
288
+
289
+ db_path, repo_root, branch, worktree_path, tmux_name = sys.argv[1:6]
290
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
291
+ slug = branch.replace("/", "-")
292
+ now = int(time.time())
293
+
294
+ conn = sqlite3.connect(db_path)
295
+ conn.execute(
296
+ """
297
+ CREATE TABLE IF NOT EXISTS sessions (
298
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
299
+ tmux_name TEXT NOT NULL UNIQUE,
300
+ repo_root TEXT NOT NULL,
301
+ worktree_path TEXT NOT NULL,
302
+ branch TEXT NOT NULL,
303
+ slug TEXT NOT NULL,
304
+ display_name TEXT,
305
+ created_at INTEGER NOT NULL,
306
+ updated_at INTEGER NOT NULL,
307
+ last_seen_at INTEGER NOT NULL
308
+ )
309
+ """
310
+ )
311
+ conn.execute(
312
+ "CREATE INDEX IF NOT EXISTS idx_sessions_worktree ON sessions(repo_root, worktree_path)"
313
+ )
314
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_slug ON sessions(repo_root, slug)")
315
+ conn.execute(
316
+ """
317
+ INSERT INTO sessions (
318
+ tmux_name, repo_root, worktree_path, branch, slug,
319
+ created_at, updated_at, last_seen_at
320
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?6)
321
+ ON CONFLICT(tmux_name) DO UPDATE SET
322
+ repo_root = excluded.repo_root,
323
+ worktree_path = excluded.worktree_path,
324
+ branch = excluded.branch,
325
+ slug = excluded.slug,
326
+ updated_at = excluded.updated_at,
327
+ last_seen_at = excluded.last_seen_at
328
+ """,
329
+ (tmux_name, repo_root, worktree_path, branch, slug, now),
330
+ )
331
+ conn.commit()
332
+ PY
333
+ }
334
+
335
+ _tmux_registry_rename_session() {
336
+ local old_tmux_name="$1"
337
+ local new_tmux_name="$2"
338
+ local display_name="${3:-}"
339
+ local db_path
340
+ db_path="$(_tmux_session_registry_path)"
341
+
342
+ [[ -f "$db_path" ]] || return 0
343
+ have_cmd python3 || return 1
344
+
345
+ python3 - "$db_path" "$old_tmux_name" "$new_tmux_name" "$display_name" <<'PY'
346
+ import sqlite3
347
+ import sys
348
+ import time
349
+
350
+ db_path, old_tmux_name, new_tmux_name, display_name = sys.argv[1:5]
351
+ now = int(time.time())
352
+ value = display_name if display_name else None
353
+
354
+ conn = sqlite3.connect(db_path)
355
+ conn.execute(
356
+ """
357
+ UPDATE sessions
358
+ SET tmux_name = ?1,
359
+ display_name = COALESCE(?2, display_name),
360
+ updated_at = ?3,
361
+ last_seen_at = ?3
362
+ WHERE tmux_name = ?4
363
+ """,
364
+ (new_tmux_name, value, now, old_tmux_name),
365
+ )
366
+ conn.commit()
367
+ PY
368
+ }
369
+
370
+ _tmux_registry_remove_session() {
371
+ local tmux_name="$1"
372
+ local db_path
373
+ db_path="$(_tmux_session_registry_path)"
374
+
375
+ [[ -f "$db_path" ]] || return 0
376
+ have_cmd python3 || return 1
377
+
378
+ python3 - "$db_path" "$tmux_name" <<'PY'
379
+ import sqlite3
380
+ import sys
381
+
382
+ db_path, tmux_name = sys.argv[1:3]
383
+ conn = sqlite3.connect(db_path)
384
+ conn.execute("DELETE FROM sessions WHERE tmux_name = ?1", (tmux_name,))
385
+ conn.commit()
386
+ PY
387
+ }
388
+
389
+ _tmux_registry_upsert_current_session() {
390
+ local session_name="$1"
391
+ local session_dir repo_root branch
392
+ session_dir="$(tmux display-message -t "$session_name" -p '#{pane_current_path}' 2>/dev/null || echo "")"
393
+ repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
394
+ branch="$(_tmux_branch_for_path "$session_dir" 2>/dev/null || true)"
395
+ [[ -n "$repo_root" && -n "$branch" ]] || return 1
396
+ _tmux_registry_upsert_session "$repo_root" "$branch" "$session_dir" "$session_name"
397
+ }
398
+
399
+ _tmux_registry_sync_active_workspace_sessions() {
400
+ local repo_root="$1"
401
+ [[ -n "$repo_root" ]] || return 1
402
+ tmux_available || return 1
403
+
404
+ local session_name session_dir session_root branch display_name
405
+ while IFS= read -r session_name; do
406
+ [[ -n "$session_name" ]] || continue
407
+ display_name="$(tmux show-option -t "$session_name" -qv @orchestra_display_name 2>/dev/null || true)"
408
+ [[ -n "$display_name" ]] || continue
409
+ session_dir="$(tmux display-message -t "$session_name" -p '#{pane_current_path}' 2>/dev/null || echo "")"
410
+ session_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
411
+ [[ "$session_root" == "$repo_root" ]] || continue
412
+ branch="$(_tmux_branch_for_path "$session_dir" 2>/dev/null || true)"
413
+ [[ -n "$branch" ]] || continue
414
+ _tmux_registry_upsert_session "$repo_root" "$branch" "$session_dir" "$session_name" >/dev/null 2>&1 || true
415
+ done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null || true)
416
+ }
417
+
418
+ _tmux_workspace_cycle_target_cached() {
419
+ local current_session="$1"
420
+ local direction="$2"
421
+ local db_path="$3"
422
+ local active_file="$4"
423
+
424
+ python3 - "$db_path" "$current_session" "$direction" "$active_file" <<'PY'
425
+ import sqlite3
426
+ import sys
427
+
428
+ db_path, current_session, direction, active_path = sys.argv[1:5]
429
+ active = set()
430
+ active_orchestra = set()
431
+ with open(active_path, "r", encoding="utf-8") as handle:
432
+ for raw_line in handle:
433
+ line = raw_line.rstrip("\n")
434
+ if not line:
435
+ continue
436
+ parts = line.split("\t")
437
+ name = parts[0].strip()
438
+ display_name = parts[1].strip() if len(parts) > 1 else ""
439
+ if not name:
440
+ continue
441
+ active.add(name)
442
+ if display_name:
443
+ active_orchestra.add(name)
444
+
445
+ try:
446
+ conn = sqlite3.connect(db_path)
447
+ current_row = conn.execute(
448
+ "SELECT repo_root FROM sessions WHERE tmux_name = ?1",
449
+ (current_session,),
450
+ ).fetchone()
451
+ if current_row is None:
452
+ raise SystemExit(2)
453
+
454
+ registered = {
455
+ name
456
+ for (name,) in conn.execute("SELECT tmux_name FROM sessions").fetchall()
457
+ }
458
+ if active_orchestra.difference(registered):
459
+ raise SystemExit(2)
460
+
461
+ rows = conn.execute(
462
+ """
463
+ SELECT tmux_name
464
+ FROM sessions
465
+ WHERE repo_root = ?1
466
+ ORDER BY worktree_path COLLATE NOCASE, created_at, tmux_name COLLATE NOCASE
467
+ """,
468
+ (current_row[0],),
469
+ ).fetchall()
470
+ except sqlite3.Error:
471
+ raise SystemExit(2)
472
+
473
+ names = []
474
+ seen = set()
475
+ for (name,) in rows:
476
+ if name in active and name not in seen:
477
+ names.append(name)
478
+ seen.add(name)
479
+
480
+ if len(names) < 2 or current_session not in seen:
481
+ raise SystemExit(3)
482
+
483
+ index = names.index(current_session)
484
+ if direction == "next":
485
+ index = (index + 1) % len(names)
486
+ else:
487
+ index = (index - 1) % len(names)
488
+ print(names[index])
489
+ PY
490
+ }
491
+
492
+ _tmux_status_escape_text() {
493
+ local text="$1"
494
+ text="${text//\#/##}"
495
+ printf '%s' "$text"
496
+ }
497
+
498
+ _tmux_truncate_tab_label() {
499
+ local label="$1"
500
+ local max_len="${2:-14}"
501
+ if (( ${#label} > max_len )); then
502
+ printf '%s' "${label:0:max_len}"
503
+ else
504
+ printf '%s' "$label"
505
+ fi
506
+ }
507
+
508
+ _tmux_pad_menu_label() {
509
+ local label="$1"
510
+ local width="${2:-56}"
511
+
512
+ label="$(_tmux_truncate_tab_label "$label" "$width")"
513
+ printf '%-*s' "$width" "$label"
514
+ }
515
+
516
+ _tmux_workspace_session_rows() {
517
+ local current_session="$1"
518
+ local repo_root="$2"
519
+ local mode="${3:-visible}"
520
+ local db_path active_file
521
+ db_path="$(_tmux_session_registry_path)"
522
+
523
+ [[ -f "$db_path" ]] || return 0
524
+ have_cmd python3 || return 0
525
+
526
+ active_file="$(mktemp)"
527
+ tmux list-sessions -F $'#{session_name}\t#{session_last_attached}\t#{session_activity}' > "$active_file" 2>/dev/null || {
528
+ rm -f "$active_file"
529
+ return 0
530
+ }
531
+
532
+ python3 - "$db_path" "$repo_root" "$current_session" "$active_file" "$mode" <<'PY'
533
+ import sqlite3
534
+ import sys
535
+
536
+ db_path, repo_root, current_session, active_path, mode = sys.argv[1:6]
537
+ def parse_tmux_time(value):
538
+ try:
539
+ return int(value.strip() or "0")
540
+ except ValueError:
541
+ return 0
542
+
543
+ active = {}
544
+ with open(active_path, "r", encoding="utf-8") as handle:
545
+ for raw_line in handle:
546
+ parts = raw_line.rstrip("\n").split("\t")
547
+ name = parts[0].strip() if parts else ""
548
+ if not name:
549
+ continue
550
+ last_attached = parse_tmux_time(parts[1]) if len(parts) > 1 else 0
551
+ activity = parse_tmux_time(parts[2]) if len(parts) > 2 else 0
552
+ active[name] = (last_attached, activity)
553
+
554
+ conn = sqlite3.connect(db_path)
555
+ rows = conn.execute(
556
+ """
557
+ SELECT tmux_name, COALESCE(display_name, '')
558
+ FROM sessions
559
+ WHERE repo_root = ?1
560
+ ORDER BY worktree_path COLLATE NOCASE, created_at, tmux_name COLLATE NOCASE
561
+ """,
562
+ (repo_root,),
563
+ ).fetchall()
564
+
565
+ filtered = []
566
+ seen = set()
567
+ for name, display_name in rows:
568
+ if name in active and name not in seen:
569
+ filtered.append((name, display_name or "", len(filtered)))
570
+ seen.add(name)
571
+
572
+ if not filtered:
573
+ raise SystemExit(0)
574
+
575
+ filtered.sort(
576
+ key=lambda row: (
577
+ row[0] != current_session,
578
+ -active.get(row[0], (0, 0))[0],
579
+ -active.get(row[0], (0, 0))[1],
580
+ row[2],
581
+ )
582
+ )
583
+
584
+ max_tabs = 8
585
+ if mode == "all" or len(filtered) <= max_tabs:
586
+ visible = filtered
587
+ prefix = suffix = False
588
+ else:
589
+ visible = filtered[:max_tabs]
590
+ prefix = False
591
+ suffix = True
592
+
593
+ if prefix:
594
+ print("__ellipsis__\t")
595
+ for name, display_name, _ in visible:
596
+ print(f"{name}\t{display_name}")
597
+ if suffix:
598
+ print("__ellipsis__\t")
599
+ PY
600
+ local status=$?
601
+ rm -f "$active_file"
602
+ return $status
603
+ }
604
+
605
+ _tmux_workspace_session_tabs() {
606
+ local current_session="$1"
607
+ local fallback_display_name="$2"
608
+ local session_dir repo_root rows tabs name display_name tab_label escaped_tab_label divider active_style inactive_style muted_style reset_style
609
+ local tab_max_width=24
610
+ local active_label_max_width=22
611
+
612
+ session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
613
+ repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
614
+ if [[ -n "$repo_root" ]]; then
615
+ _tmux_registry_sync_active_workspace_sessions "$repo_root" >/dev/null 2>&1 || true
616
+ rows="$(_tmux_workspace_session_rows "$current_session" "$repo_root" 2>/dev/null || true)"
617
+ else
618
+ rows=""
619
+ fi
620
+
621
+ if [[ -z "$rows" ]]; then
622
+ tab_label="$(_tmux_truncate_tab_label "$fallback_display_name" "$active_label_max_width")"
623
+ escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
624
+ printf '#[bg=#414868] #[fg=#ff9e64,bg=#414868,bold]● #[fg=#c0caf5,bg=#414868,bold]%s #[default]' "$escaped_tab_label"
625
+ return
626
+ fi
627
+
628
+ # Tokyo Night palette: midnight footer, muted inactive tabs, orange active marker.
629
+ active_style="#[fg=#c0caf5,bg=#414868,bold]"
630
+ inactive_style="#[fg=#a9b1d6,bg=#24283b,nobold]"
631
+ muted_style="#[fg=#565f89,bg=#1a1b26,nobold]"
632
+ reset_style="#[default]"
633
+ tabs=""
634
+ divider="${muted_style}|${reset_style}"
635
+ while IFS=$'\t' read -r name display_name; do
636
+ [[ -n "$name" ]] || continue
637
+ if [[ -n "$tabs" ]]; then
638
+ tabs+="$divider"
639
+ fi
640
+ if [[ "$name" == "__ellipsis__" ]]; then
641
+ tab_label="⋯"
642
+ escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
643
+ tabs+="${muted_style} ${escaped_tab_label} ${reset_style}"
644
+ continue
645
+ fi
646
+
647
+ if [[ -z "$display_name" ]]; then
648
+ display_name="$(tmux_format_session_display "$name" without-timestamp)"
649
+ fi
650
+ if [[ "$name" == "$current_session" ]]; then
651
+ tab_label="$(_tmux_truncate_tab_label "$display_name" "$active_label_max_width")"
652
+ escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
653
+ tabs+="#[bg=#414868] #[fg=#ff9e64,bg=#414868,bold]● ${active_style}${escaped_tab_label} ${reset_style}"
654
+ else
655
+ tab_label="$(_tmux_truncate_tab_label "$display_name" "$tab_max_width")"
656
+ escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
657
+ tabs+="${inactive_style} ${escaped_tab_label} ${reset_style}"
658
+ fi
659
+ done <<< "$rows"
660
+
661
+ printf '%s' "$tabs"
662
+ }
663
+
664
+ _tmux_orchestra_status_left() {
665
+ local session_name="$1"
666
+ local session_display_name="$3"
667
+ _tmux_workspace_session_tabs "$session_name" "$session_display_name"
668
+ }
669
+
670
+ _tmux_orchestra_status_right() {
671
+ printf '#[fg=#565f89,bg=#1a1b26]Ctrl+b,h for help#[default]'
672
+ }
673
+
674
+ _tmux_configure_orchestra_bindings() {
675
+ local services
676
+ services="$(_orchestra_services_script)"
677
+ [[ -f "$services" ]] || return
678
+
679
+ local quoted_services rename_command prompt_command next_command previous_command new_session_command list_command close_command close_prompt_command help_command
680
+ printf -v quoted_services '%q' "$services"
681
+ rename_command="$quoted_services manual-rename-session \\\"#{session_name}\\\" \\\"%%\\\" >/dev/null 2>&1"
682
+ prompt_command="command-prompt -p 'Rename Orchestra session:' 'run-shell -b \"$rename_command\"'"
683
+ next_command="$quoted_services cycle-workspace-session \\\"#{session_name}\\\" next \\\"#{client_tty}\\\" >/dev/null 2>&1"
684
+ previous_command="$quoted_services cycle-workspace-session \\\"#{session_name}\\\" previous \\\"#{client_tty}\\\" >/dev/null 2>&1"
685
+ new_session_command="$quoted_services create-workspace-session \\\"#{session_name}\\\" \\\"#{client_tty}\\\" >/dev/null 2>&1 || true"
686
+ list_command="$quoted_services workspace-session-menu \\\"#{session_name}\\\" \\\"#{client_tty}\\\" >/dev/null 2>&1 || true"
687
+ close_command="$quoted_services close-workspace-session \\\"#{session_name}\\\" \\\"#{client_tty}\\\" >/dev/null 2>&1 || true"
688
+ close_prompt_command="confirm-before -p 'Close current Orchestra session? (y/n)' 'run-shell -b \"$close_command\"'"
689
+ help_command="$quoted_services tmux-help-popup \\\"#{client_tty}\\\" >/dev/null 2>&1 || true"
690
+
691
+ tmux bind-key -T prefix '?' if-shell -F '#{@orchestra_display_name}' \
692
+ "run-shell -b \"$help_command\"" 'list-keys -N' >/dev/null 2>&1 || true
693
+ tmux bind-key -T prefix h if-shell -F '#{@orchestra_display_name}' \
694
+ "run-shell -b \"$help_command\"" 'refresh-client -S' >/dev/null 2>&1 || true
695
+ tmux bind-key -T prefix n if-shell -F '#{@orchestra_display_name}' \
696
+ "run-shell -b \"$new_session_command\"" 'next-window' >/dev/null 2>&1 || true
697
+ tmux bind-key -T prefix l if-shell -F '#{@orchestra_display_name}' \
698
+ "run-shell -b \"$list_command\"" 'last-window' >/dev/null 2>&1 || true
699
+ tmux bind-key -T prefix q if-shell -F '#{@orchestra_display_name}' \
700
+ "$close_prompt_command" 'display-panes' >/dev/null 2>&1 || true
701
+ tmux bind-key -T prefix X if-shell -F '#{@orchestra_display_name}' \
702
+ "$close_prompt_command" 'confirm-before -p "kill-session #S? (y/n)" kill-session' >/dev/null 2>&1 || true
703
+ tmux bind-key -T prefix r if-shell -F '#{@orchestra_display_name}' \
704
+ "$prompt_command" 'refresh-client -S' >/dev/null 2>&1 || true
705
+ tmux bind-key -T prefix '>' if-shell -F '#{@orchestra_display_name}' \
706
+ "run-shell -b \"$next_command\"" 'switch-client -n' >/dev/null 2>&1 || true
707
+ tmux bind-key -T prefix '<' if-shell -F '#{@orchestra_display_name}' \
708
+ "run-shell -b \"$previous_command\"" 'switch-client -p' >/dev/null 2>&1 || true
709
+ tmux bind-key -r -T prefix Right if-shell -F '#{@orchestra_display_name}' \
710
+ "run-shell -b \"$next_command\"" 'select-pane -R' >/dev/null 2>&1 || true
711
+ tmux bind-key -r -T prefix Left if-shell -F '#{@orchestra_display_name}' \
712
+ "run-shell -b \"$previous_command\"" 'select-pane -L' >/dev/null 2>&1 || true
713
+ }
714
+
715
+ _tmux_configure_orchestra_status() {
716
+ local session_name="$1"
717
+ local worktree_name="$2"
718
+ local session_display_name="${3:-}"
719
+ local status_left status_right
720
+ if [[ -z "$session_display_name" ]]; then
721
+ session_display_name="$(tmux_format_session_display "$session_name" without-timestamp)"
722
+ fi
723
+ status_left="$(_tmux_orchestra_status_left "$session_name" "$worktree_name" "$session_display_name")"
724
+ status_right="$(_tmux_orchestra_status_right "$worktree_name")"
725
+
726
+ tmux set-option -t "$session_name" @orchestra_display_name "$session_display_name" >/dev/null 2>&1 || true
727
+ tmux set-option -t "$session_name" @orchestra_worktree_name "$worktree_name" >/dev/null 2>&1 || true
728
+ tmux set-option -t "$session_name" status on >/dev/null 2>&1 || true
729
+ tmux set-option -t "$session_name" status-position bottom >/dev/null 2>&1 || true
730
+ tmux set-option -t "$session_name" status-style "fg=#c0caf5,bg=#1a1b26" >/dev/null 2>&1 || true
731
+ tmux set-option -t "$session_name" status-left "$status_left" >/dev/null 2>&1 || true
732
+ tmux set-option -t "$session_name" status-left-length 1000 >/dev/null 2>&1 || true
733
+ tmux set-option -t "$session_name" status-right "$status_right" >/dev/null 2>&1 || true
734
+ tmux set-option -t "$session_name" status-right-length 40 >/dev/null 2>&1 || true
735
+ tmux set-option -t "$session_name" window-status-format "" >/dev/null 2>&1 || true
736
+ tmux set-option -t "$session_name" window-status-current-format "" >/dev/null 2>&1 || true
737
+ tmux set-option -t "$session_name" window-status-separator "" >/dev/null 2>&1 || true
738
+ _tmux_configure_orchestra_bindings
739
+ }
740
+
741
+ _tmux_refresh_orchestra_session_status() {
742
+ local session_name="$1"
743
+ local session_dir branch_name worktree_name display_name old_pwd
744
+
745
+ session_dir="$(tmux display-message -t "$session_name" -p '#{pane_current_path}' 2>/dev/null || echo "")"
746
+ branch_name=""
747
+ if [[ -n "$session_dir" && -d "$session_dir" ]]; then
748
+ old_pwd="$PWD"
749
+ cd "$session_dir" 2>/dev/null || true
750
+ branch_name="$(git_current_branch 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
751
+ cd "$old_pwd" 2>/dev/null || true
752
+ fi
753
+
754
+ if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
755
+ worktree_name="$branch_name"
756
+ elif [[ -n "$session_dir" && -d "$session_dir" ]]; then
757
+ worktree_name="$(basename "$session_dir")"
758
+ else
759
+ worktree_name="$branch_name"
760
+ fi
761
+
762
+ display_name="$(tmux show-option -t "$session_name" -qv @orchestra_display_name 2>/dev/null || true)"
763
+ _tmux_configure_orchestra_status "$session_name" "$worktree_name" "$display_name"
764
+ }
765
+
766
+ # Helper: split a string by multi-char delimiter into bash array named by ref
767
+ # Usage: _tmux_split_by_delim "string" "::" out_array_name
768
+ _tmux_split_by_delim() {
769
+ local _s="$1" _d="$2" _ref="$3"
770
+ local _arr=()
771
+ if [[ -z "$_d" ]]; then
772
+ _arr=("$_s")
773
+ else
774
+ while :; do
775
+ if [[ "$_s" == *"$_d"* ]]; then
776
+ _arr+=("${_s%%"$_d"*}")
777
+ _s="${_s#*"$_d"}"
778
+ else
779
+ _arr+=("$_s")
780
+ break
781
+ fi
782
+ done
783
+ fi
784
+ # Use printf with %q to properly quote array elements for eval
785
+ local _quoted=()
786
+ local _item
787
+ for _item in "${_arr[@]}"; do
788
+ printf -v _q "%q" "$_item"
789
+ _quoted+=("$_q")
790
+ done
791
+ eval "$_ref=( ${_quoted[*]} )"
792
+ }
793
+
794
+
795
+ # Check if tmux is available
796
+ tmux_available() {
797
+ have_cmd tmux
798
+ }
799
+
800
+ # Check if currently inside a tmux session
801
+ tmux_inside_session() {
802
+ [[ -n "${TMUX-}" ]]
803
+ }
804
+
805
+ # Generate random readable name for tmux sessions
806
+ tmux_generate_readable_name() {
807
+ local adjectives=(
808
+ "swift" "brave" "clever" "gentle" "bright" "calm" "eager" "fierce" "happy" "kind"
809
+ "lively" "noble" "proud" "quick" "smart" "wise" "bold" "cool" "daring" "epic"
810
+ "fuzzy" "jolly" "lucky" "merry" "peppy" "rosy" "sunny" "zesty" "crisp" "fresh"
811
+ )
812
+ local animals=(
813
+ "bear" "wolf" "fox" "eagle" "hawk" "lion" "tiger" "panda" "otter" "seal"
814
+ "whale" "shark" "dolphin" "falcon" "raven" "deer" "moose" "lynx" "badger" "heron"
815
+ "phoenix" "dragon" "griffin" "unicorn" "pegasus" "kraken" "sphinx" "chimera" "hydra" "basilisk"
816
+ )
817
+
818
+ local adj_idx=$((RANDOM % ${#adjectives[@]}))
819
+ local animal_idx=$((RANDOM % ${#animals[@]}))
820
+
821
+ echo "${adjectives[$adj_idx]}_${animals[$animal_idx]}"
822
+ }
823
+
824
+ # --------------------------- Session Management -----------------------------
825
+
826
+ # Create a new tmux session
827
+ # Usage: tmux_create_session <session_name> <working_directory>
828
+ tmux_create_session() {
829
+ local session_name="$1"
830
+ local working_dir="$2"
831
+
832
+ if ! tmux_available; then
833
+ err "tmux not installed"
834
+ return 1
835
+ fi
836
+
837
+ # Get repository info from the working directory context
838
+ local repo_name=""
839
+ local branch_name=""
840
+ local repo_root=""
841
+ local old_pwd="$PWD"
842
+ local resolved_working_dir="$working_dir"
843
+ if [[ -n "$working_dir" && -d "$working_dir" ]]; then
844
+ resolved_working_dir="$(cd "$working_dir" 2>/dev/null && pwd -P || printf '%s' "$working_dir")"
845
+ fi
846
+
847
+ # Change to working directory to get accurate git info
848
+ cd "$resolved_working_dir" 2>/dev/null || true
849
+
850
+ if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
851
+ repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
852
+ branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
853
+ repo_root="$(git_shared_root 2>/dev/null || git_repo_root 2>/dev/null || true)"
854
+ fi
855
+
856
+ # Restore original directory
857
+ cd "$old_pwd" 2>/dev/null || true
858
+
859
+ # Ensure orchestra prefix (configurable delimiter) on session name to mark origin
860
+ local ORCH_PREFIX
861
+ ORCH_PREFIX="$(_tmux_orch_prefix)"
862
+ if [[ "$session_name" != ${ORCH_PREFIX}* ]]; then
863
+ session_name="${ORCH_PREFIX}${session_name}"
864
+ fi
865
+
866
+ # Create session with custom Orchestra status configuration
867
+ tmux new-session -Ad -s "$session_name" -c "$resolved_working_dir" >/dev/null 2>&1 || {
868
+ err "Failed to create tmux session: $session_name"
869
+ return 1
870
+ }
871
+
872
+ local worktree_name=""
873
+ if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
874
+ worktree_name="$branch_name"
875
+ else
876
+ worktree_name="$(basename "$resolved_working_dir")"
877
+ fi
878
+
879
+ if [[ -n "$repo_root" && -n "$branch_name" && "$branch_name" != "detached" ]]; then
880
+ _tmux_registry_upsert_session "$repo_root" "$branch_name" "$resolved_working_dir" "$session_name" >/dev/null 2>&1 || true
881
+ fi
882
+
883
+ # Customize the default status bar to include Orchestra info on the left
884
+ if [[ -n "$repo_name" ]]; then
885
+ _tmux_configure_orchestra_status "$session_name" "$worktree_name"
886
+ fi
887
+
888
+ echo "$session_name"
889
+ }
890
+
891
+ # Generate a short hash from a path for unique session identification
892
+ # Usage: tmux_path_hash <path>
893
+ tmux_path_hash() {
894
+ local path="$1"
895
+ # Use MD5 hash of the path, take first 8 chars
896
+ # Works on both macOS and Linux
897
+ if command -v md5sum >/dev/null 2>&1; then
898
+ echo -n "$path" | md5sum | cut -c1-8
899
+ elif command -v md5 >/dev/null 2>&1; then
900
+ echo -n "$path" | md5 -q | cut -c1-8
901
+ else
902
+ # Fallback: use cksum if neither md5 available
903
+ echo -n "$path" | cksum | cut -d' ' -f1 | cut -c1-8
904
+ fi
905
+ }
906
+
907
+ # Alias for backward compatibility
908
+ tmux_repo_hash() {
909
+ tmux_path_hash "$1"
910
+ }
911
+
912
+ # Ensure a session exists for slug+name in worktree; prints session name
913
+ # Creates sessions with format: [worktreename]_[worktreetreehash]_[datetime]_[readable_name]
914
+ # Usage: tmux_ensure_session <slug> <name> <worktree_path>
915
+ tmux_ensure_session() {
916
+ local slug="$1"
917
+ local name="$2"
918
+ local wt="$3"
919
+ local d
920
+ d="$(_tmux_delim)"
921
+ local date_part time_part
922
+ date_part="$(date +%Y%m%d)"
923
+ time_part="$(date +%H%M%S)"
924
+
925
+ # Use a repo-scoped hash to avoid cross-repo collisions
926
+ # Hash the absolute worktree path (backward-compat listing supports old slug-hash)
927
+ local worktree_hash
928
+ worktree_hash="$(tmux_path_hash "$wt")"
929
+
930
+ # If name is provided, use it; otherwise generate a random readable name with auto_ prefix
931
+ if [[ -z "$name" || "$name" == "main" ]]; then
932
+ name="auto_$(tmux_generate_readable_name)"
933
+ fi
934
+
935
+ local sess="${slug}${d}${worktree_hash}${d}${date_part}${d}${time_part}${d}${name}"
936
+ tmux_create_session "$sess" "$wt"
937
+ _tmux_source_command_hook "$sess"
938
+ }
939
+
940
+ # Create a new auto-named Orchestra session in the current session's worktree and switch to it.
941
+ # Uses the same tmux_ensure_session path as the TUI tree view create action.
942
+ # Usage: tmux_create_workspace_session <current_session> [client_tty]
943
+ tmux_create_workspace_session() {
944
+ local current_session="$1"
945
+ local target_client="${2:-}"
946
+ local session_dir branch slug new_session
947
+
948
+ if ! tmux_available; then
949
+ err "tmux not installed"
950
+ return 1
951
+ fi
952
+ if [[ -z "$current_session" ]]; then
953
+ err "tmux_create_workspace_session: current session required"
954
+ return 1
955
+ fi
956
+
957
+ session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
958
+ if [[ -z "$session_dir" || ! -d "$session_dir" ]]; then
959
+ err "Unable to determine current worktree path"
960
+ return 1
961
+ fi
962
+
963
+ branch="$(_tmux_branch_for_path "$session_dir" 2>/dev/null || true)"
964
+ if [[ -z "$branch" || "$branch" == "detached" ]]; then
965
+ err "Unable to determine current worktree branch"
966
+ return 1
967
+ fi
968
+
969
+ slug="$(git_branch_to_slug "$branch")"
970
+ new_session="$(tmux_ensure_session "$slug" "" "$session_dir")" || return 1
971
+
972
+ if [[ -n "$target_client" ]]; then
973
+ tmux switch-client -c "$target_client" -t "$new_session" >/dev/null 2>&1 || return 1
974
+ else
975
+ tmux switch-client -t "$new_session" >/dev/null 2>&1 || return 1
976
+ fi
977
+
978
+ printf '%s\n' "$new_session"
979
+ }
980
+
981
+ # Close the current Orchestra session after switching the client to the next one.
982
+ # Usage: tmux_close_workspace_session <current_session> [client_tty]
983
+ tmux_close_workspace_session() {
984
+ local current_session="$1"
985
+ local target_client="${2:-}"
986
+ local target_session
987
+
988
+ if ! tmux_available; then
989
+ err "tmux not installed"
990
+ return 1
991
+ fi
992
+ if [[ -z "$current_session" ]]; then
993
+ err "tmux_close_workspace_session: current session required"
994
+ return 1
995
+ fi
996
+
997
+ if ! target_session="$(tmux_workspace_cycle_target "$current_session" next)"; then
998
+ tmux display-message -d 2500 "No other Orchestra sessions; current session kept" 2>/dev/null || true
999
+ return 1
1000
+ fi
1001
+
1002
+ if [[ -n "$target_client" ]]; then
1003
+ tmux switch-client -c "$target_client" -t "$target_session" >/dev/null 2>&1 || return 1
1004
+ else
1005
+ tmux switch-client -t "$target_session" >/dev/null 2>&1 || return 1
1006
+ fi
1007
+
1008
+ tmux_kill_session "$current_session" >/dev/null 2>&1 || return 1
1009
+ _tmux_refresh_orchestra_session_status "$target_session" >/dev/null 2>&1 || true
1010
+ }
1011
+
1012
+ # Check if a session exists
1013
+ # Usage: tmux_session_exists <session_name>
1014
+ tmux_session_exists() {
1015
+ local session_name="$1"
1016
+ tmux_available && tmux has-session -t "$session_name" 2>/dev/null
1017
+ }
1018
+
1019
+ # Kill/delete a tmux session
1020
+ # Usage: tmux_kill_session <session_name>
1021
+ tmux_kill_session() {
1022
+ local session_name="$1"
1023
+
1024
+ if ! tmux_available; then
1025
+ err "tmux not installed"
1026
+ return 1
1027
+ fi
1028
+
1029
+ tmux kill-session -t "$session_name" 2>/dev/null || {
1030
+ err "Failed to kill session: $session_name"
1031
+ return 1
1032
+ }
1033
+ _tmux_registry_remove_session "$session_name" >/dev/null 2>&1 || true
1034
+ }
1035
+
1036
+ # Attach or switch to session
1037
+ # Usage: tmux_attach_session <session_name>
1038
+ tmux_attach_session() {
1039
+ local sess="$1"
1040
+
1041
+ if ! tmux_available; then
1042
+ err "tmux not installed"
1043
+ return 1
1044
+ fi
1045
+
1046
+ _tmux_source_command_hook "$sess"
1047
+
1048
+ # Get repository info for terminal title and banner update
1049
+ local repo_name=""
1050
+ local branch_name=""
1051
+ local session_dir=""
1052
+
1053
+ # Try to get the session's working directory
1054
+ session_dir="$(tmux display-message -t "$sess" -p '#{pane_current_path}' 2>/dev/null || echo "")"
1055
+
1056
+ if [[ -n "$session_dir" ]] && [[ -d "$session_dir" ]]; then
1057
+ # Get git info from session's directory
1058
+ local old_pwd="$PWD"
1059
+ cd "$session_dir" 2>/dev/null || true
1060
+
1061
+ if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
1062
+ repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
1063
+ branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
1064
+ fi
1065
+
1066
+ cd "$old_pwd" 2>/dev/null || true
1067
+ else
1068
+ # Fallback to current directory
1069
+ if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
1070
+ repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
1071
+ branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
1072
+ fi
1073
+ fi
1074
+
1075
+ # Set terminal window title (works in most terminal emulators)
1076
+ if [[ -n "$repo_name" ]]; then
1077
+ printf "\033]0;🎼 Orchestra: %s [%s]\007" "$repo_name" "$branch_name" >/dev/tty 2>/dev/null || true
1078
+ fi
1079
+
1080
+ # Show welcome message when attaching
1081
+ if [[ -n "$repo_name" ]]; then
1082
+ tmux display-message -t "$sess" -d 2000 \
1083
+ "🎼 Orchestra Session | Repository: $repo_name | Branch: $branch_name" 2>/dev/null || true
1084
+ fi
1085
+
1086
+ _tmux_refresh_orchestra_session_status "$sess"
1087
+
1088
+ if tmux_inside_session; then
1089
+ tmux switch-client -t "$sess" >/dev/null 2>&1 || true
1090
+ else
1091
+ tmux attach -t "$sess" >/dev/null 2>&1 || true
1092
+ fi
1093
+ }
1094
+
1095
+ # Rename a tmux session while preserving the worktree prefix, repo hash, and datetime
1096
+ # Usage: tmux_rename_session <old_session_name> <new_name>
1097
+ tmux_rename_session() {
1098
+ local old_session="$1"
1099
+ local new_name="$2"
1100
+
1101
+ if ! tmux_available; then
1102
+ err "tmux not installed"
1103
+ return 1
1104
+ fi
1105
+
1106
+ local d ORCH_PREFIX
1107
+ d="$(_tmux_delim)"
1108
+ ORCH_PREFIX="$(_tmux_orch_prefix)"
1109
+
1110
+ # Detect and strip orchestra prefix for parsing
1111
+ local orch=""
1112
+ local base="$old_session"
1113
+ if [[ "$base" == ${ORCH_PREFIX}* ]]; then
1114
+ orch="$ORCH_PREFIX"
1115
+ base="${base#${ORCH_PREFIX}}"
1116
+ fi
1117
+
1118
+ # Split by current delimiter and scan from the right to tolerate delimiter inside segments
1119
+ local parts
1120
+ _tmux_split_by_delim "$base" "$d" parts
1121
+
1122
+ local n=${#parts[@]}
1123
+ local idx_time=-1 idx_date=-1 idx_hash=-1
1124
+ local i
1125
+ for (( i=n-1; i>=0; i-- )); do
1126
+ if [[ ${parts[$i]} =~ ^[0-9]{6}$ ]]; then idx_time=$i; break; fi
1127
+ done
1128
+ if (( idx_time > 0 )) && [[ ${parts[$((idx_time-1))]} =~ ^[0-9]{8}$ ]]; then
1129
+ idx_date=$((idx_time-1))
1130
+ fi
1131
+ if (( idx_date > 0 )) && [[ ${parts[$((idx_date-1))]} =~ ^[0-9a-f]{8}$ ]]; then
1132
+ idx_hash=$((idx_date-1))
1133
+ fi
1134
+
1135
+ local prefix=""
1136
+ if (( idx_time >= 0 && idx_date >= 0 )); then
1137
+ # Build prefix: slug + d + [hash + d] + date + d + time + d
1138
+ local upto=$idx_time
1139
+ local j
1140
+ for (( j=0; j<=upto; j++ )); do
1141
+ if (( j > 0 )); then prefix+="$d"; fi
1142
+ prefix+="${parts[$j]}"
1143
+ done
1144
+ prefix+="$d"
1145
+
1146
+ # Debug logging for rename parsing
1147
+ if [[ -n "${GW_DEBUG_RENAME-}" || -n "${DEBUG-}" ]]; then
1148
+ >&2 echo "[orchestra] rename DEBUG: old='$old_session' base='$base' delim='$d'"
1149
+ >&2 echo "[orchestra] parts: ${parts[*]}"
1150
+ >&2 echo "[orchestra] idx_time=$idx_time idx_date=$idx_date idx_hash=$idx_hash"
1151
+ >&2 echo "[orchestra] prefix='${orch}${prefix}' new_name='$new_name'"
1152
+ fi
1153
+ else
1154
+ err "Invalid session format"
1155
+ return 1
1156
+ fi
1157
+
1158
+ local new_session="${orch}${prefix}${new_name}"
1159
+
1160
+ tmux rename-session -t "$old_session" "$new_session" 2>/dev/null || {
1161
+ err "Failed to rename session"
1162
+ return 1
1163
+ }
1164
+ _tmux_registry_rename_session "$old_session" "$new_session" "$(format_session_display_name "$new_name")" >/dev/null 2>&1 || true
1165
+ local worktree_name
1166
+ worktree_name="$(tmux show-option -t "$new_session" -qv @orchestra_worktree_name 2>/dev/null || true)"
1167
+ if [[ -z "$worktree_name" ]]; then
1168
+ local session_dir old_pwd branch_name
1169
+ session_dir="$(tmux display-message -t "$new_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
1170
+ if [[ -n "$session_dir" && -d "$session_dir" ]]; then
1171
+ old_pwd="$PWD"
1172
+ cd "$session_dir" 2>/dev/null || true
1173
+ branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
1174
+ cd "$old_pwd" 2>/dev/null || true
1175
+ if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
1176
+ worktree_name="$branch_name"
1177
+ else
1178
+ worktree_name="$(basename "$session_dir")"
1179
+ fi
1180
+ fi
1181
+ fi
1182
+ _tmux_configure_orchestra_status "$new_session" "$worktree_name"
1183
+ >&2 echo "✏️ Renamed session to: $new_name"
1184
+ return 0
1185
+ }
1186
+
1187
+ # --------------------------- Session Discovery ------------------------------
1188
+
1189
+ # Get tmux sessions for a slug prefix; prints session names sorted by creation time, oldest first
1190
+ # Expected session format: [worktreename]_[worktreehash]_[datetime]_[readable_name]
1191
+ # Usage: tmux_list_sessions_for_slug <slug> [worktree_path]
1192
+ tmux_list_sessions_for_slug() {
1193
+ local slug="$1"
1194
+ local worktree_path="${2:-}"
1195
+ tmux_available || return 0
1196
+
1197
+ local d ORCH_PREFIX
1198
+ d="$(_tmux_delim)"
1199
+ ORCH_PREFIX="$(_tmux_orch_prefix)"
1200
+
1201
+ # If worktree_path provided, match hash-based and no-hash prefixes
1202
+ if [[ -n "$worktree_path" ]]; then
1203
+ local hash_slug hash_path branch_name
1204
+ hash_slug="$(tmux_path_hash "$slug")"
1205
+ hash_path="$(tmux_path_hash "$worktree_path")"
1206
+ branch_name="$(git_worktree_path_to_branch "$worktree_path" 2>/dev/null || true)"
1207
+
1208
+ local p1_new="${ORCH_PREFIX}${slug}${d}${hash_slug}${d}"
1209
+ local p2_new="${slug}${d}${hash_slug}${d}"
1210
+ local p1_old="${ORCH_PREFIX}${slug}${d}${hash_path}${d}"
1211
+ local p2_old="${slug}${d}${hash_path}${d}"
1212
+ local p1_branch_hash=""
1213
+ local p2_branch_hash=""
1214
+ if [[ -n "$branch_name" ]]; then
1215
+ p1_branch_hash="${ORCH_PREFIX}${branch_name}${d}${hash_path}${d}"
1216
+ p2_branch_hash="${branch_name}${d}${hash_path}${d}"
1217
+ fi
1218
+
1219
+ # List sessions with any known prefix variant
1220
+ tmux list-sessions -F '#{session_name}|||#{session_created}' 2>/dev/null \
1221
+ | sed 's/|||/\t/g' \
1222
+ | awk -v a="$p1_new" -v b="$p2_new" -v c="$p1_old" -v d="$p2_old" -v e="$p1_branch_hash" -v f="$p2_branch_hash" 'BEGIN{FS="\t"} (length(a)>0 && index($1, a)==1) || (length(b)>0 && index($1, b)==1) || (length(c)>0 && index($1, c)==1) || (length(d)>0 && index($1, d)==1) || (length(e)>0 && index($1, e)==1) || (length(f)>0 && index($1, f)==1) {print $1"\t"$2}' \
1223
+ | sort -t $'\t' -k2,2n -k1,1f \
1224
+ | awk -F '\t' '{print $1}' | awk '!seen[$0]++' || true
1225
+ else
1226
+ # New-format prefix matching without hash: orchestra__slug__... or slug__...
1227
+ local prefix1="${ORCH_PREFIX}${slug}${d}"
1228
+ local prefix2="${slug}${d}"
1229
+ tmux list-sessions -F '#{session_name}|||#{session_created}' 2>/dev/null \
1230
+ | sed 's/|||/\t/g' \
1231
+ | awk -v p1="$prefix1" -v p2="$prefix2" 'BEGIN{FS="\t"} index($1, p1)==1 || index($1, p2)==1 {print $1"\t"$2}' \
1232
+ | sort -t $'\t' -k2,2n -k1,1f \
1233
+ | awk -F '\t' '{print $1}' || true
1234
+ fi
1235
+ }
1236
+
1237
+ # List all tmux sessions
1238
+ # Usage: tmux_list_all_sessions
1239
+ tmux_list_all_sessions() {
1240
+ tmux_available || return 0
1241
+ tmux list-sessions -F '#{session_name}' 2>/dev/null || true
1242
+ }
1243
+
1244
+ # Find sessions matching a pattern
1245
+ # Usage: tmux_find_session <pattern>
1246
+ tmux_find_session() {
1247
+ local pattern="$1"
1248
+
1249
+ if ! tmux_available; then
1250
+ return 1
1251
+ fi
1252
+
1253
+ # Try exact match first
1254
+ if tmux_session_exists "$pattern"; then
1255
+ echo "$pattern"
1256
+ return 0
1257
+ fi
1258
+
1259
+ # Try pattern match
1260
+ tmux_list_all_sessions | grep -E "$pattern" | head -1 || true
1261
+ }
1262
+
1263
+ # --------------------------- Session Information ----------------------------
1264
+
1265
+ # Helper function to format session display names
1266
+ # Input: session_content (e.g., "opencode_fixing_auth_bug" or "my_feature_work")
1267
+ # Output: "Opencode Fixing Auth Bug" or "My Feature Work"
1268
+ format_session_display_name() {
1269
+ local session_content="$1"
1270
+ local d
1271
+ d="$(_tmux_delim)"
1272
+ local ORCH_PREFIX
1273
+ ORCH_PREFIX="$(_tmux_orch_prefix)"
1274
+ if [[ "$session_content" == ${ORCH_PREFIX}* ]]; then
1275
+ session_content="${session_content#${ORCH_PREFIX}}"
1276
+ fi
1277
+ if [[ "$session_content" == auto_* ]]; then
1278
+ session_content="${session_content#auto_}"
1279
+ fi
1280
+ local description
1281
+ description="$(echo "$session_content" | tr '_-' ' ')"
1282
+ description="$(echo "$description" | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2));}1')"
1283
+ echo "$description"
1284
+ }
1285
+
1286
+ _tmux_format_session_timestamp() {
1287
+ local date_part="$1"
1288
+ local time_part="$2"
1289
+
1290
+ if [[ ! "$date_part" =~ ^[0-9]{8}$ || ! "$time_part" =~ ^[0-9]{6}$ ]]; then
1291
+ printf '%s %s\n' "$date_part" "$time_part"
1292
+ return
1293
+ fi
1294
+
1295
+ local month day hour minute ampm display_hour
1296
+ month=$((10#${date_part:4:2}))
1297
+ day=$((10#${date_part:6:2}))
1298
+ hour=$((10#${time_part:0:2}))
1299
+ minute="${time_part:2:2}"
1300
+
1301
+ ampm="am"
1302
+ display_hour="$hour"
1303
+ if (( hour >= 12 )); then
1304
+ ampm="pm"
1305
+ if (( hour > 12 )); then
1306
+ display_hour=$((hour - 12))
1307
+ fi
1308
+ fi
1309
+ if (( hour == 0 )); then
1310
+ display_hour=12
1311
+ fi
1312
+
1313
+ local month_names=("" "Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec")
1314
+ local month_name="???"
1315
+ if (( month > 0 && month <= 12 )); then
1316
+ month_name="${month_names[$month]}"
1317
+ fi
1318
+
1319
+ printf '%s %s %s:%s%s\n' "$month_name" "$day" "$display_hour" "$minute" "$ampm"
1320
+ }
1321
+
1322
+ # Parse session name and format for display
1323
+ # Input: session_name (format: worktreename_repohash_YYYYMMDD_HHMMSS_readable_name or old formats)
1324
+ # Output: formatted display name
1325
+ # Usage: tmux_format_session_display <session_name>
1326
+ tmux_format_session_display() {
1327
+ local session_name="$1"
1328
+ local timestamp_mode="${2:-with-timestamp}"
1329
+
1330
+ # Handle temporary renaming sessions first
1331
+ if [[ "$session_name" =~ _renaming$ ]]; then
1332
+ # Extract base name before _renaming suffix
1333
+ local base_name="${session_name%_renaming}"
1334
+ # Recursively process the base name to get proper display
1335
+ tmux_format_session_display "$base_name" "$timestamp_mode"
1336
+ return $?
1337
+ fi
1338
+
1339
+ local d ORCH_PREFIX
1340
+ d="$(_tmux_delim)"
1341
+ ORCH_PREFIX="$(_tmux_orch_prefix)"
1342
+
1343
+ if [[ "$session_name" == ${ORCH_PREFIX}* ]]; then
1344
+ session_name="${session_name#${ORCH_PREFIX}}"
1345
+ elif [[ "$session_name" =~ ^orchestra_(.+)$ ]]; then
1346
+ session_name="${BASH_REMATCH[1]}"
1347
+ fi
1348
+
1349
+ local parts
1350
+ _tmux_split_by_delim "$session_name" "$d" parts
1351
+ local idx_time=-1 idx_date=-1 i
1352
+ for (( i=${#parts[@]}-1; i>=0; i-- )); do
1353
+ if [[ ${parts[$i]} =~ ^[0-9]{6}$ ]] && (( i > 0 )) && [[ ${parts[$((i-1))]} =~ ^[0-9]{8}$ ]]; then
1354
+ idx_time=$i
1355
+ idx_date=$((i-1))
1356
+ break
1357
+ fi
1358
+ done
1359
+ if (( idx_time >= 0 && idx_date >= 0 )); then
1360
+ local readable_name=""
1361
+ local j
1362
+ for (( j=idx_time+1; j<${#parts[@]}; j++ )); do
1363
+ if [[ -n "$readable_name" ]]; then
1364
+ readable_name+="$d"
1365
+ fi
1366
+ readable_name+="${parts[$j]}"
1367
+ done
1368
+ if [[ -n "$readable_name" ]]; then
1369
+ local formatted_name timestamp
1370
+ formatted_name="$(format_session_display_name "$readable_name")"
1371
+ timestamp="$(_tmux_format_session_timestamp "${parts[$idx_date]}" "${parts[$idx_time]}")"
1372
+ if [[ "$timestamp_mode" == "without-timestamp" ]]; then
1373
+ echo "$formatted_name"
1374
+ else
1375
+ echo "${formatted_name} (${timestamp})"
1376
+ fi
1377
+ return 0
1378
+ fi
1379
+ fi
1380
+
1381
+ # New format with repo hash: worktreename_repohash_YYYYMMDD_HHMMSS_readable_name
1382
+ if [[ "$session_name" =~ ^[^_]+_[a-f0-9]{8}_([0-9]{8})_([0-9]{6})_(.+)$ ]]; then
1383
+ local date_part="${BASH_REMATCH[1]}" # YYYYMMDD
1384
+ local time_part="${BASH_REMATCH[2]}" # HHMMSS
1385
+ local readable_name="${BASH_REMATCH[3]}" # readable_name
1386
+
1387
+ # Parse date: YYYYMMDD -> Jul 21
1388
+ local year="${date_part:0:4}"
1389
+ local month="${date_part:4:2}"
1390
+ local day="${date_part:6:2}"
1391
+
1392
+ # Parse time: HHMMSS -> 12:30am
1393
+ local hour="${time_part:0:2}"
1394
+ local minute="${time_part:2:2}"
1395
+
1396
+ # Convert to 12-hour format
1397
+ local ampm="am"
1398
+ local display_hour="$hour"
1399
+ if [[ "$hour" -ge 12 ]]; then
1400
+ ampm="pm"
1401
+ if [[ "$hour" -gt 12 ]]; then
1402
+ display_hour=$((hour - 12))
1403
+ fi
1404
+ fi
1405
+ if [[ "$hour" == "00" ]]; then
1406
+ display_hour="12"
1407
+ fi
1408
+
1409
+ # Remove leading zero from hour
1410
+ display_hour="${display_hour#0}"
1411
+
1412
+ # Convert month number to name
1413
+ local month_names=("" "Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec")
1414
+ local month_name="${month_names[${month#0}]}"
1415
+
1416
+ # Remove leading zero from day
1417
+ local display_day="${day#0}"
1418
+
1419
+ # Format the readable name with proper formatting
1420
+ local formatted_name="$(format_session_display_name "$readable_name")"
1421
+
1422
+ if [[ "$timestamp_mode" == "without-timestamp" ]]; then
1423
+ echo "$formatted_name"
1424
+ else
1425
+ echo "${formatted_name} (${month_name} ${display_day} ${display_hour}:${minute}${ampm})"
1426
+ fi
1427
+ # Fallback: Try format without repo hash: appname_description_YYYYMMDD_HHMMSS
1428
+ elif [[ "$session_name" =~ ^(.*)_[0-9]{8}_[0-9]{6}$ ]]; then
1429
+ local session_content="${BASH_REMATCH[1]}" # appname_description
1430
+
1431
+ # Convert to proper display format
1432
+ local formatted_name="$(format_session_display_name "$session_content")"
1433
+ echo "$formatted_name"
1434
+ else
1435
+ # Check for old format without repo hash: worktreename_YYYYMMDD_HHMMSS_readable_name
1436
+ if [[ "$session_name" =~ ^[^_]+_([0-9]{8})_([0-9]{6})_(.+)$ ]]; then
1437
+ local date_part="${BASH_REMATCH[1]}" # YYYYMMDD
1438
+ local time_part="${BASH_REMATCH[2]}" # HHMMSS
1439
+ local readable_name="${BASH_REMATCH[3]}" # readable_name
1440
+
1441
+ # Parse date: YYYYMMDD -> Jul 21
1442
+ local year="${date_part:0:4}"
1443
+ local month="${date_part:4:2}"
1444
+ local day="${date_part:6:2}"
1445
+
1446
+ # Parse time: HHMMSS -> 12:30am
1447
+ local hour="${time_part:0:2}"
1448
+ local minute="${time_part:2:2}"
1449
+
1450
+ # Convert to 12-hour format
1451
+ local ampm="am"
1452
+ local display_hour="$hour"
1453
+ if [[ "$hour" -ge 12 ]]; then
1454
+ ampm="pm"
1455
+ if [[ "$hour" -gt 12 ]]; then
1456
+ display_hour=$((hour - 12))
1457
+ fi
1458
+ fi
1459
+ if [[ "$hour" == "00" ]]; then
1460
+ display_hour="12"
1461
+ fi
1462
+
1463
+ # Remove leading zero from hour
1464
+ display_hour="${display_hour#0}"
1465
+
1466
+ # Convert month number to name
1467
+ local month_names=("" "Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec")
1468
+ local month_name="${month_names[${month#0}]}"
1469
+
1470
+ # Remove leading zero from day
1471
+ local display_day="${day#0}"
1472
+
1473
+ # Format the readable name with proper formatting
1474
+ local formatted_name="$(format_session_display_name "$readable_name")"
1475
+
1476
+ if [[ "$timestamp_mode" == "without-timestamp" ]]; then
1477
+ echo "$formatted_name"
1478
+ else
1479
+ echo "${formatted_name} (${month_name} ${display_day} ${display_hour}:${minute}${ampm})"
1480
+ fi
1481
+ else
1482
+ # Fallback for sessions that don't match either format
1483
+ echo "$session_name"
1484
+ fi
1485
+ fi
1486
+ }
1487
+
1488
+ # Get active pane id for a session (best effort)
1489
+ # Usage: tmux_get_active_pane <session_name>
1490
+ tmux_get_active_pane() {
1491
+ local s="$1"
1492
+ tmux_available || return 1
1493
+
1494
+ # Find active window id
1495
+ local win
1496
+ win="$(tmux list-windows -t "$s" -F '#{window_active} #{window_id}' 2>/dev/null | awk '$1==1{print $2; exit}')" || true
1497
+ [[ -z "$win" ]] && return 1
1498
+
1499
+ # Find active pane id within that window
1500
+ tmux list-panes -t "$win" -F '#{pane_active} #{pane_id}' 2>/dev/null | awk '$1==1{print $2; exit}' || true
1501
+ }
1502
+
1503
+ # Capture enhanced session preview showing current terminal view (bottom lines)
1504
+ # Usage: tmux_session_preview <session_name>
1505
+ tmux_session_preview() {
1506
+ local s="$1"
1507
+ tmux_available || { echo "(tmux not installed)"; return 0; }
1508
+
1509
+ local pane
1510
+ pane="$(tmux_get_active_pane "$s" || true)"
1511
+ if [[ -z "$pane" ]]; then
1512
+ echo "(no active pane found)"
1513
+ return 0
1514
+ fi
1515
+
1516
+ # Always capture from the BOTTOM of the terminal buffer (last visible lines)
1517
+ # -e flag preserves escape sequences for colors and formatting
1518
+ # -p prints to stdout
1519
+ # NO -S/-E flags means capture the current viewport (what's visible NOW)
1520
+ # This ensures we always see the bottom/most recent output
1521
+ local txt
1522
+ txt="$(tmux capture-pane -e -p -t "$pane" 2>/dev/null)"
1523
+
1524
+ if [[ -z "$txt" ]]; then
1525
+ echo "(no output yet)"
1526
+ return 0
1527
+ fi
1528
+
1529
+ # Check if the pane is idle (cursor at prompt, no running process)
1530
+ local pane_cmd pane_mode
1531
+ pane_cmd="$(tmux display-message -t "$pane" -p '#{pane_current_command}' 2>/dev/null || echo "")"
1532
+ pane_mode="$(tmux display-message -t "$pane" -p '#{pane_mode}' 2>/dev/null || echo "")"
1533
+
1534
+ # Detect idle state: shell running and not in copy mode
1535
+ local is_idle="false"
1536
+ case "$pane_cmd" in
1537
+ bash|zsh|sh|fish|dash|ksh)
1538
+ # Shell is running, check if we're at a prompt (not in copy mode)
1539
+ if [[ "$pane_mode" == "" ]]; then
1540
+ is_idle="true"
1541
+ fi
1542
+ ;;
1543
+ esac
1544
+
1545
+ # Optimized: Get terminal info in a single command instead of multiple calls
1546
+ local term_info has_rgb
1547
+ term_info="$(tmux show-environment -t "$s" TERM 2>/dev/null | cut -d= -f2 || echo "unknown")"
1548
+
1549
+ # Fast check for RGB support without full grep
1550
+ if tmux show-options -t "$s" -s terminal-overrides 2>/dev/null | grep -q "RGB"; then
1551
+ has_rgb="true"
1552
+ else
1553
+ has_rgb="false"
1554
+ fi
1555
+
1556
+ # For ANSI-preserved preview with color mode info
1557
+ # Add markers for the Rust parser to detect color capabilities and idle state
1558
+ if [[ "$has_rgb" == "true" || "$term_info" == *"direct"* || "$term_info" == *"truecolor"* ]]; then
1559
+ echo "<<<COLORMODE:RGB>>>"
1560
+ elif [[ "$term_info" == *"256color"* ]]; then
1561
+ echo "<<<COLORMODE:256>>>"
1562
+ else
1563
+ echo "<<<COLORMODE:16>>>"
1564
+ fi
1565
+
1566
+ # Add idle marker so Rust can stop polling when terminal is idle
1567
+ if [[ "$is_idle" == "true" ]]; then
1568
+ echo "<<<IDLE:true>>>"
1569
+ else
1570
+ echo "<<<IDLE:false>>>"
1571
+ fi
1572
+
1573
+ # If no content after processing, show placeholder
1574
+ if [[ -z "$txt" ]]; then
1575
+ echo "(session active, no visible output)"
1576
+ else
1577
+ echo "$txt"
1578
+ fi
1579
+ }
1580
+
1581
+ # --------------------------- Advanced Operations ----------------------------
1582
+
1583
+ # Send keys to a session and press Enter
1584
+ # Usage: tmux_send_keys <session_name> <command...>
1585
+ tmux_send_keys() {
1586
+ local session_name="$1"; shift || true
1587
+ local command_line="$*"
1588
+ if ! tmux_available; then
1589
+ err "tmux not installed"
1590
+ return 1
1591
+ fi
1592
+ if [[ -z "$session_name" || -z "$command_line" ]]; then
1593
+ err "tmux_send_keys: session and command required"
1594
+ return 1
1595
+ fi
1596
+ tmux send-keys -t "$session_name" -l -- "$command_line" 2>/dev/null || return 1
1597
+ tmux send-keys -t "$session_name" C-m 2>/dev/null || return 1
1598
+ }
1599
+
1600
+ # Show Orchestra tmux shortcuts in a popup modal.
1601
+ # Usage: tmux_show_orchestra_help_popup [client_tty]
1602
+ tmux_show_orchestra_help_popup() {
1603
+ local target_client="${1:-}"
1604
+ if ! tmux_available; then
1605
+ err "tmux not installed"
1606
+ return 1
1607
+ fi
1608
+
1609
+ local target_args=()
1610
+ if [[ -n "$target_client" ]]; then
1611
+ target_args=(-c "$target_client")
1612
+ fi
1613
+
1614
+ local popup_command
1615
+ popup_command="$(cat <<'EOF'
1616
+ bash -lc '
1617
+ trap "exit 0" INT TERM
1618
+ printf "%s\n" \
1619
+ "Orchestra tmux shortcuts" \
1620
+ "" \
1621
+ "Ctrl+b, d Detach and return to Orchestra" \
1622
+ "Ctrl+b, r Rename the current Orchestra session" \
1623
+ "Ctrl+b, n New Orchestra session in this worktree" \
1624
+ "Ctrl+b, q Close current session and switch next" \
1625
+ "Ctrl+b, l List active sessions" \
1626
+ "" \
1627
+ "Ctrl+b, Left Previous Orchestra session in this workspace" \
1628
+ "Ctrl+b, Right Next Orchestra session in this workspace" \
1629
+ "Ctrl+b, < Previous Orchestra session in this workspace" \
1630
+ "Ctrl+b, > Next Orchestra session in this workspace" \
1631
+ "" \
1632
+ "Ctrl+b, [ Copy/scroll mode" \
1633
+ "Ctrl+b, h Show this help" \
1634
+ "Ctrl+b, ? Show this help" \
1635
+ "" \
1636
+ "Press any key to close..."
1637
+ IFS= read -rsn1 _ || true
1638
+ exit 0
1639
+ '
1640
+ EOF
1641
+ )"
1642
+
1643
+ tmux display-popup "${target_args[@]}" \
1644
+ -E \
1645
+ -w 84 \
1646
+ -h 22 \
1647
+ -s "fg=#c0caf5,bg=#1a1b26" \
1648
+ -S "fg=#7aa2f7,bg=#1a1b26" \
1649
+ -T "Orchestra shortcuts" \
1650
+ "$popup_command" >/dev/null 2>&1 || true
1651
+ }
1652
+
1653
+ # Show active Orchestra sessions in a native tmux menu for arrow-key selection.
1654
+ # Usage: tmux_show_workspace_session_menu <current_session> [client_tty]
1655
+ tmux_show_workspace_session_menu() {
1656
+ local current_session="$1"
1657
+ local target_client="${2:-}"
1658
+ local session_dir repo_root rows name display_name label target_command
1659
+ local menu_width=56
1660
+ local min_menu_rows=10
1661
+ local menu_row_count=0
1662
+
1663
+ if ! tmux_available; then
1664
+ err "tmux not installed"
1665
+ return 1
1666
+ fi
1667
+ if [[ -z "$current_session" ]]; then
1668
+ err "tmux_show_workspace_session_menu: current session required"
1669
+ return 1
1670
+ fi
1671
+
1672
+ session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
1673
+ repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
1674
+ if [[ -z "$repo_root" ]]; then
1675
+ tmux display-message -d 2500 "Unable to determine Orchestra workspace" 2>/dev/null || true
1676
+ return 1
1677
+ fi
1678
+
1679
+ _tmux_registry_sync_active_workspace_sessions "$repo_root" >/dev/null 2>&1 || true
1680
+ rows="$(_tmux_workspace_session_rows "$current_session" "$repo_root" all 2>/dev/null || true)"
1681
+ if [[ -z "$rows" ]]; then
1682
+ tmux display-message -d 2500 "No active Orchestra sessions in this workspace" 2>/dev/null || true
1683
+ return 1
1684
+ fi
1685
+
1686
+ local target_args=()
1687
+ if [[ -n "$target_client" ]]; then
1688
+ target_args=(-c "$target_client")
1689
+ fi
1690
+
1691
+ local menu_items=()
1692
+ while IFS=$'\t' read -r name display_name; do
1693
+ [[ -n "$name" && "$name" != "__ellipsis__" ]] || continue
1694
+ if [[ -z "$display_name" ]]; then
1695
+ display_name="$(tmux_format_session_display "$name" without-timestamp)"
1696
+ fi
1697
+ label="$(_tmux_truncate_tab_label "$display_name" 42)"
1698
+ if [[ "$name" == "$current_session" ]]; then
1699
+ label="● $label"
1700
+ else
1701
+ label=" $label"
1702
+ fi
1703
+ label="$(_tmux_pad_menu_label "$label" "$menu_width")"
1704
+ label="$(_tmux_status_escape_text "$label")"
1705
+ target_command="switch-client -t \"$name\""
1706
+ menu_items+=("$label" "" "$target_command")
1707
+ menu_row_count=$((menu_row_count + 1))
1708
+ done <<< "$rows"
1709
+
1710
+ if [[ ${#menu_items[@]} -eq 0 ]]; then
1711
+ tmux display-message -d 2500 "No active Orchestra sessions in this workspace" 2>/dev/null || true
1712
+ return 1
1713
+ fi
1714
+
1715
+ while (( menu_row_count < min_menu_rows )); do
1716
+ menu_items+=("$(_tmux_pad_menu_label "" "$menu_width")" "" "")
1717
+ menu_row_count=$((menu_row_count + 1))
1718
+ done
1719
+
1720
+ tmux display-menu "${target_args[@]}" \
1721
+ -s "fg=#c0caf5,bg=#1a1b26" \
1722
+ -S "fg=#7aa2f7,bg=#1a1b26" \
1723
+ -H "fg=#1a1b26,bg=#ff9e64,bold" \
1724
+ -T "Orchestra sessions" \
1725
+ "${menu_items[@]}" >/dev/null 2>&1 || true
1726
+ }
1727
+
1728
+ # Find the adjacent active Orchestra session registered for the current repo.
1729
+ # Usage: tmux_workspace_cycle_target <current_session> <next|previous>
1730
+ tmux_workspace_cycle_target() {
1731
+ local current_session="$1"
1732
+ local direction="$2"
1733
+ local session_dir repo_root db_path active_file target query_status
1734
+
1735
+ if ! tmux_available; then
1736
+ err "tmux not installed"
1737
+ return 1
1738
+ fi
1739
+ if [[ -z "$current_session" ]]; then
1740
+ err "tmux_workspace_cycle_target: current session required"
1741
+ return 1
1742
+ fi
1743
+ case "$direction" in
1744
+ next|previous|prev) ;;
1745
+ *)
1746
+ err "tmux_workspace_cycle_target: direction must be next or previous"
1747
+ return 1
1748
+ ;;
1749
+ esac
1750
+
1751
+ db_path="$(_tmux_session_registry_path)"
1752
+ have_cmd python3 || {
1753
+ err "python3 is required to read the Orchestra session registry"
1754
+ return 1
1755
+ }
1756
+
1757
+ active_file="$(mktemp)"
1758
+ tmux list-sessions -F $'#{session_name}\t#{@orchestra_display_name}\t#{session_last_attached}\t#{session_activity}' > "$active_file" 2>/dev/null || {
1759
+ rm -f "$active_file"
1760
+ err "Unable to list tmux sessions"
1761
+ return 1
1762
+ }
1763
+
1764
+ if [[ -f "$db_path" ]]; then
1765
+ if target="$(_tmux_workspace_cycle_target_cached "$current_session" "$direction" "$db_path" "$active_file")"; then
1766
+ query_status=0
1767
+ else
1768
+ query_status=$?
1769
+ fi
1770
+ if [[ $query_status -eq 0 && -n "$target" ]]; then
1771
+ rm -f "$active_file"
1772
+ printf '%s\n' "$target"
1773
+ return 0
1774
+ fi
1775
+ if [[ $query_status -eq 3 ]]; then
1776
+ rm -f "$active_file"
1777
+ return 1
1778
+ fi
1779
+ fi
1780
+
1781
+ _tmux_registry_upsert_current_session "$current_session" >/dev/null 2>&1 || true
1782
+
1783
+ session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
1784
+ repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
1785
+ if [[ -z "$repo_root" ]]; then
1786
+ rm -f "$active_file"
1787
+ err "Unable to determine Orchestra workspace for session"
1788
+ return 1
1789
+ fi
1790
+ _tmux_registry_sync_active_workspace_sessions "$repo_root" >/dev/null 2>&1 || true
1791
+
1792
+ if [[ ! -f "$db_path" ]]; then
1793
+ rm -f "$active_file"
1794
+ err "No Orchestra session registry found"
1795
+ return 1
1796
+ fi
1797
+
1798
+ if target="$(python3 - "$db_path" "$repo_root" "$current_session" "$direction" "$active_file" <<'PY'
1799
+ import sqlite3
1800
+ import sys
1801
+
1802
+ db_path, repo_root, current_session, direction, active_path = sys.argv[1:6]
1803
+ with open(active_path, "r", encoding="utf-8") as handle:
1804
+ active = {
1805
+ line.rstrip("\n").split("\t", 1)[0].strip()
1806
+ for line in handle
1807
+ if line.rstrip("\n").split("\t", 1)[0].strip()
1808
+ }
1809
+
1810
+ conn = sqlite3.connect(db_path)
1811
+ rows = conn.execute(
1812
+ """
1813
+ SELECT tmux_name
1814
+ FROM sessions
1815
+ WHERE repo_root = ?1
1816
+ ORDER BY worktree_path COLLATE NOCASE, created_at, tmux_name COLLATE NOCASE
1817
+ """,
1818
+ (repo_root,),
1819
+ ).fetchall()
1820
+
1821
+ names = []
1822
+ seen = set()
1823
+ for (name,) in rows:
1824
+ if name in active and name not in seen:
1825
+ names.append(name)
1826
+ seen.add(name)
1827
+
1828
+ if len(names) < 2 or current_session not in seen:
1829
+ raise SystemExit(0)
1830
+
1831
+ index = names.index(current_session)
1832
+ if direction == "next":
1833
+ index = (index + 1) % len(names)
1834
+ else:
1835
+ index = (index - 1) % len(names)
1836
+ print(names[index])
1837
+ PY
1838
+ )"; then
1839
+ query_status=0
1840
+ else
1841
+ query_status=$?
1842
+ fi
1843
+ rm -f "$active_file"
1844
+ if [[ $query_status -ne 0 ]]; then
1845
+ err "Unable to query Orchestra session registry"
1846
+ return 1
1847
+ fi
1848
+ [[ -n "$target" ]] || return 1
1849
+ printf '%s\n' "$target"
1850
+ }
1851
+
1852
+ # Switch the current tmux client to an adjacent registered Orchestra session.
1853
+ # Usage: tmux_cycle_workspace_session <current_session> <next|previous> [client_tty]
1854
+ tmux_cycle_workspace_session() {
1855
+ local current_session="$1"
1856
+ local direction="$2"
1857
+ local target_client="${3:-}"
1858
+ local target_session
1859
+
1860
+ if ! target_session="$(tmux_workspace_cycle_target "$current_session" "$direction")"; then
1861
+ tmux display-message -d 2500 "No other Orchestra sessions in this workspace" 2>/dev/null || true
1862
+ return 1
1863
+ fi
1864
+
1865
+ if [[ -n "$target_client" ]]; then
1866
+ tmux switch-client -c "$target_client" -t "$target_session" >/dev/null 2>&1 || return 1
1867
+ else
1868
+ tmux switch-client -t "$target_session" >/dev/null 2>&1 || return 1
1869
+ fi
1870
+
1871
+ {
1872
+ _tmux_refresh_orchestra_session_status "$target_session" >/dev/null 2>&1 || true
1873
+ } &
1874
+ }
1875
+
1876
+ # Load .env file if it exists
1877
+ # Usage: tmux_load_env_file [env_file_path]
1878
+ tmux_load_env_file() {
1879
+ local env_file="${1:-$PWD/.env}"
1880
+ if [[ -f "$env_file" ]]; then
1881
+ # Source the .env file, but only export ANTHROPIC_API_KEY
1882
+ set -a # Auto-export variables
1883
+ source "$env_file"
1884
+ set +a # Turn off auto-export
1885
+ fi
1886
+ }
1887
+
1888
+ # Load Anthropic API key from config file or fallback to .env files
1889
+ tmux_load_anthropic_api_key() {
1890
+ # First try to load from ~/.orchestra/config.json
1891
+ local config_file="$HOME/.orchestra/config.json"
1892
+ if [[ -f "$config_file" ]] && command -v jq >/dev/null 2>&1; then
1893
+ local api_key
1894
+ api_key="$(jq -r '.anthropic_api_key // empty' "$config_file" 2>/dev/null)"
1895
+ if [[ -n "$api_key" ]] && [[ "$api_key" != "null" ]]; then
1896
+ export ANTHROPIC_API_KEY="$api_key"
1897
+ return 0
1898
+ fi
1899
+ fi
1900
+
1901
+ # Fallback to .env file loading (existing logic)
1902
+ tmux_load_env_file "$PWD/.env"
1903
+
1904
+ # If still no API key, try repo root
1905
+ if [[ -z "${ANTHROPIC_API_KEY-}" ]]; then
1906
+ local root
1907
+ root="$(repo_root)"
1908
+ if [[ -n "$root" ]]; then
1909
+ tmux_load_env_file "$root/.env"
1910
+ fi
1911
+ fi
1912
+ }
1913
+
1914
+ # Load OpenAI API key from config file or fallback to .env files
1915
+ tmux_load_openai_api_key() {
1916
+ local config_file="$HOME/.orchestra/config.json"
1917
+ if [[ -f "$config_file" ]]; then
1918
+ local api_key=""
1919
+ if command -v jq >/dev/null 2>&1; then
1920
+ api_key="$(jq -r '.openai_api_key // empty' "$config_file" 2>/dev/null)"
1921
+ elif command -v python3 >/dev/null 2>&1; then
1922
+ api_key="$(python3 - "$config_file" <<'PY'
1923
+ import json
1924
+ import sys
1925
+ try:
1926
+ data = json.load(open(sys.argv[1], 'r'))
1927
+ except Exception:
1928
+ data = {}
1929
+ value = data.get('openai_api_key') or ''
1930
+ print(value)
1931
+ PY
1932
+ )"
1933
+ elif command -v node >/dev/null 2>&1; then
1934
+ api_key="$(node - "$config_file" <<'NODE'
1935
+ const fs = require('fs');
1936
+ let value = '';
1937
+ try {
1938
+ const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1939
+ value = data.openai_api_key || '';
1940
+ } catch (err) {
1941
+ value = '';
1942
+ }
1943
+ process.stdout.write(value);
1944
+ NODE
1945
+ )"
1946
+ fi
1947
+ if [[ -n "$api_key" ]] && [[ "$api_key" != "null" ]]; then
1948
+ export OPENAI_API_KEY="$api_key"
1949
+ return 0
1950
+ fi
1951
+ fi
1952
+
1953
+ tmux_load_env_file "$PWD/.env"
1954
+ if [[ -z "${OPENAI_API_KEY-}" ]]; then
1955
+ local root
1956
+ root="$(repo_root)"
1957
+ if [[ -n "$root" ]]; then
1958
+ tmux_load_env_file "$root/.env"
1959
+ fi
1960
+ fi
1961
+ }
1962
+
1963
+ # Load primary AI provider from config (defaults to anthropic)
1964
+ tmux_load_ai_primary_provider() {
1965
+ if [[ -n "${AI_PRIMARY_PROVIDER-}" ]]; then
1966
+ return 0
1967
+ fi
1968
+ local config_file="$HOME/.orchestra/config.json"
1969
+ local provider=""
1970
+ if [[ -f "$config_file" ]]; then
1971
+ if command -v jq >/dev/null 2>&1; then
1972
+ provider="$(jq -r '.ai_primary_provider // empty' "$config_file" 2>/dev/null)"
1973
+ elif command -v python3 >/dev/null 2>&1; then
1974
+ provider="$(python3 - "$config_file" <<'PY'
1975
+ import json
1976
+ import sys
1977
+ try:
1978
+ data = json.load(open(sys.argv[1], 'r'))
1979
+ except Exception:
1980
+ data = {}
1981
+ value = data.get('ai_primary_provider') or ''
1982
+ print(value)
1983
+ PY
1984
+ )"
1985
+ elif command -v node >/dev/null 2>&1; then
1986
+ provider="$(node - "$config_file" <<'NODE'
1987
+ const fs = require('fs');
1988
+ let value = '';
1989
+ try {
1990
+ const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1991
+ value = data.ai_primary_provider || '';
1992
+ } catch (err) {
1993
+ value = '';
1994
+ }
1995
+ process.stdout.write(value);
1996
+ NODE
1997
+ )"
1998
+ fi
1999
+ fi
2000
+ provider="$(printf '%s' "$provider" | tr '[:upper:]' '[:lower:]')"
2001
+ if [[ "$provider" != "openai" && "$provider" != "anthropic" ]]; then
2002
+ provider="anthropic"
2003
+ fi
2004
+ export AI_PRIMARY_PROVIDER="$provider"
2005
+ }
2006
+
2007
+ # Load OpenAI model from config (defaults to gpt-4o-mini)
2008
+ tmux_load_openai_model() {
2009
+ if [[ -n "${OPENAI_MODEL-}" ]]; then
2010
+ return 0
2011
+ fi
2012
+ local config_file="$HOME/.orchestra/config.json"
2013
+ local model=""
2014
+ if [[ -f "$config_file" ]]; then
2015
+ if command -v jq >/dev/null 2>&1; then
2016
+ model="$(jq -r '.openai_model // empty' "$config_file" 2>/dev/null)"
2017
+ elif command -v python3 >/dev/null 2>&1; then
2018
+ model="$(python3 - "$config_file" <<'PY'
2019
+ import json
2020
+ import sys
2021
+ try:
2022
+ data = json.load(open(sys.argv[1], 'r'))
2023
+ except Exception:
2024
+ data = {}
2025
+ value = data.get('openai_model') or ''
2026
+ print(value)
2027
+ PY
2028
+ )"
2029
+ elif command -v node >/dev/null 2>&1; then
2030
+ model="$(node - "$config_file" <<'NODE'
2031
+ const fs = require('fs');
2032
+ let value = '';
2033
+ try {
2034
+ const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
2035
+ value = data.openai_model || '';
2036
+ } catch (err) {
2037
+ value = '';
2038
+ }
2039
+ process.stdout.write(value);
2040
+ NODE
2041
+ )"
2042
+ fi
2043
+ fi
2044
+ if [[ -z "$model" || "$model" == "null" ]]; then
2045
+ model="gpt-4o-mini"
2046
+ fi
2047
+ export OPENAI_MODEL="$model"
2048
+ }
2049
+
2050
+ # Generate a descriptive name for a tmux session using AI
2051
+ # Usage: tmux_generate_ai_session_name <session_name>
2052
+ tmux_generate_ai_session_name() {
2053
+ local session="$1"
2054
+
2055
+ # Load AI provider config and API keys
2056
+ tmux_load_ai_primary_provider
2057
+ tmux_load_openai_model
2058
+ tmux_load_anthropic_api_key
2059
+ tmux_load_openai_api_key
2060
+
2061
+ local primary="${AI_PRIMARY_PROVIDER:-anthropic}"
2062
+ primary="$(printf '%s' "$primary" | tr '[:upper:]' '[:lower:]')"
2063
+ local provider=""
2064
+ if [[ "$primary" == "openai" ]]; then
2065
+ if [[ -n "${OPENAI_API_KEY-}" ]]; then
2066
+ provider="openai"
2067
+ elif [[ -n "${ANTHROPIC_API_KEY-}" ]]; then
2068
+ provider="anthropic"
2069
+ fi
2070
+ else
2071
+ if [[ -n "${ANTHROPIC_API_KEY-}" ]]; then
2072
+ provider="anthropic"
2073
+ elif [[ -n "${OPENAI_API_KEY-}" ]]; then
2074
+ provider="openai"
2075
+ fi
2076
+ fi
2077
+
2078
+ if [[ -z "$provider" ]]; then
2079
+ err "No AI API key found in config file or .env file"
2080
+ return 1
2081
+ fi
2082
+
2083
+ local openai_model="${OPENAI_MODEL:-gpt-4o-mini}"
2084
+
2085
+ local pane_cmd pane_mode window_name pane_title alternate_on mouse_any_flag
2086
+ pane_cmd="$(tmux display-message -t "$session" -p '#{pane_current_command}' 2>/dev/null || echo "")"
2087
+ pane_mode="$(tmux display-message -t "$session" -p '#{pane_mode}' 2>/dev/null || echo "")"
2088
+ window_name="$(tmux display-message -t "$session" -p '#{window_name}' 2>/dev/null || echo "")"
2089
+ pane_title="$(tmux display-message -t "$session" -p '#{pane_title}' 2>/dev/null || echo "")"
2090
+ alternate_on="$(tmux display-message -t "$session" -p '#{alternate_on}' 2>/dev/null || echo "0")"
2091
+ mouse_any_flag="$(tmux display-message -t "$session" -p '#{mouse_any_flag}' 2>/dev/null || echo "0")"
2092
+
2093
+ local history_temp=""
2094
+ local history_arg=""
2095
+ local history_dir="${ORCHESTRA_HISTORY_DIR:-$HOME/.orchestra/history}"
2096
+ local pane_id
2097
+ pane_id="$(tmux display-message -t "$session" -p '#{pane_id}' 2>/dev/null || echo "")"
2098
+ local pane_key=""
2099
+ if [[ -n "$pane_id" ]]; then
2100
+ pane_key="$(_orchestra_history_key "$pane_id")"
2101
+ fi
2102
+ local session_key
2103
+ session_key="$(_orchestra_history_key "$session")"
2104
+
2105
+ local is_tui="false"
2106
+ if _tmux_is_tui_context "$pane_cmd" "$alternate_on" "$mouse_any_flag" "$pane_mode" "$window_name" "$pane_title"; then
2107
+ is_tui="true"
2108
+ fi
2109
+
2110
+ local content=""
2111
+ local visible_content=""
2112
+ if [[ "$is_tui" == "true" ]]; then
2113
+ visible_content="$(tmux capture-pane -e -p -q -a -t "${pane_id:-$session}" 2>/dev/null || echo "")"
2114
+ fi
2115
+ local scrollback_content
2116
+ scrollback_content="$(tmux capture-pane -t "$session" -p -S -200 2>/dev/null)" || {
2117
+ err "Failed to capture tmux pane content"
2118
+ return 1
2119
+ }
2120
+ if [[ -n "$visible_content" ]]; then
2121
+ content="### Visible terminal view\n${visible_content}\n\n### Recent scrollback\n${scrollback_content}"
2122
+ else
2123
+ content="$scrollback_content"
2124
+ fi
2125
+
2126
+ local history_path=""
2127
+ if [[ -n "$pane_key" && -f "$history_dir/$pane_key.log" ]]; then
2128
+ history_path="$history_dir/$pane_key.log"
2129
+ elif [[ -n "$session_key" && -f "$history_dir/$session_key.log" ]]; then
2130
+ history_path="$history_dir/$session_key.log"
2131
+ fi
2132
+
2133
+ if [[ -n "$history_path" ]]; then
2134
+ if [[ "$OSTYPE" == "darwin"* ]]; then
2135
+ history_temp=$(mktemp -t orchestra_hist)
2136
+ else
2137
+ history_temp=$(mktemp)
2138
+ fi
2139
+ tail -n 50 "$history_path" > "$history_temp"
2140
+ history_arg="$history_temp"
2141
+ fi
2142
+
2143
+ local metadata_block=""
2144
+ metadata_block+="Session metadata:\n"
2145
+ metadata_block+="- pane_current_command: ${pane_cmd:-unknown}\n"
2146
+ metadata_block+="- window_name: ${window_name:-}\n"
2147
+ metadata_block+="- pane_title: ${pane_title:-}\n"
2148
+ metadata_block+="- pane_mode: ${pane_mode:-}\n"
2149
+ metadata_block+="- alternate_screen_active: ${alternate_on:-0}\n"
2150
+ metadata_block+="- mouse_mode_active: ${mouse_any_flag:-0}\n"
2151
+ metadata_block+="- likely_tui_app: ${is_tui}\n"
2152
+
2153
+ # If content is empty or too short, keep a placeholder
2154
+ if [[ ${#content} -lt 10 ]]; then
2155
+ content="(no terminal output captured)"
2156
+ fi
2157
+
2158
+ # Truncate content if too long (to stay within token limits)
2159
+ if [[ ${#content} -gt 8000 ]]; then
2160
+ content="${content: -8000}"
2161
+ fi
2162
+
2163
+ # Create a temporary file for the content to avoid escaping issues
2164
+ # mktemp works differently on macOS vs Linux
2165
+ local temp_file
2166
+ if [[ "$OSTYPE" == "darwin"* ]]; then
2167
+ temp_file=$(mktemp -t orchestra_tmp)
2168
+ else
2169
+ temp_file=$(mktemp)
2170
+ fi
2171
+ printf '%s' "$content" > "$temp_file"
2172
+
2173
+ # Prepare the API request using Python for proper JSON encoding
2174
+ local request_body=""
2175
+ if have_cmd python3; then
2176
+ # Use Python to safely build the request body and extract typed commands from the capture
2177
+ request_payload=$(
2178
+ python3 - "$temp_file" "$history_arg" "$provider" "$openai_model" "$pane_cmd" "$window_name" "$pane_title" "$pane_mode" "$alternate_on" "$mouse_any_flag" "$is_tui" 2>/dev/null <<'PYCODE'
2179
+ import json
2180
+ import re
2181
+ import sys
2182
+ from pathlib import Path
2183
+
2184
+ temp_path = Path(sys.argv[1])
2185
+ content = temp_path.read_text()
2186
+ history_arg = sys.argv[2] if len(sys.argv) > 2 else ""
2187
+ provider = sys.argv[3] if len(sys.argv) > 3 else "anthropic"
2188
+ openai_model = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else "gpt-4o-mini"
2189
+ pane_cmd = sys.argv[5] if len(sys.argv) > 5 else ""
2190
+ window_name = sys.argv[6] if len(sys.argv) > 6 else ""
2191
+ pane_title = sys.argv[7] if len(sys.argv) > 7 else ""
2192
+ pane_mode = sys.argv[8] if len(sys.argv) > 8 else ""
2193
+ alternate_on = sys.argv[9] if len(sys.argv) > 9 else "0"
2194
+ mouse_any_flag = sys.argv[10] if len(sys.argv) > 10 else "0"
2195
+ is_tui = sys.argv[11] if len(sys.argv) > 11 else "false"
2196
+
2197
+ lines = content.splitlines()
2198
+ command_candidates = [] # Commands captured with high confidence
2199
+ fallback_candidates = [] # All prompt-like lines (used if we fail to classify)
2200
+
2201
+ history_commands = []
2202
+ if history_arg:
2203
+ hist_path = Path(history_arg)
2204
+ if hist_path.exists():
2205
+ for line in hist_path.read_text().splitlines():
2206
+ line = line.strip()
2207
+ if not line:
2208
+ continue
2209
+ if "\t" in line:
2210
+ history_commands.append(line.split("\t", 1)[1])
2211
+ else:
2212
+ history_commands.append(line)
2213
+ history_commands = history_commands[-50:]
2214
+
2215
+ prompt_pattern = re.compile(r"^[A-Za-z0-9_.@~/-]+$")
2216
+ prompt_sigil_pattern = re.compile(r"^(?P<prompt>[^\t]{0,120}?)(?P<sigil>[$#%])\s+(?P<cmd>.+)$")
2217
+ simple_sigil_pattern = re.compile(r"^(?P<sigil>[$#%])\s+(?P<cmd>.+)$")
2218
+ disallowed_prompts = {
2219
+ "warning",
2220
+ "error",
2221
+ "fatal",
2222
+ "hint",
2223
+ "note",
2224
+ "usage",
2225
+ "info",
2226
+ "debug",
2227
+ "trace",
2228
+ }
2229
+
2230
+ disallowed_first_tokens = {
2231
+ "warning",
2232
+ "error",
2233
+ "fatal",
2234
+ "hint",
2235
+ "note",
2236
+ "usage",
2237
+ "not",
2238
+ "see",
2239
+ "for",
2240
+ "from",
2241
+ "with",
2242
+ "and",
2243
+ "or",
2244
+ "but",
2245
+ "at",
2246
+ "to",
2247
+ "in",
2248
+ "info",
2249
+ "debug",
2250
+ "trace",
2251
+ }
2252
+
2253
+ common_commands = {
2254
+ "git",
2255
+ "gh",
2256
+ "npm",
2257
+ "pnpm",
2258
+ "yarn",
2259
+ "node",
2260
+ "npx",
2261
+ "bun",
2262
+ "cargo",
2263
+ "go",
2264
+ "python",
2265
+ "python3",
2266
+ "pip",
2267
+ "pip3",
2268
+ "poetry",
2269
+ "pipenv",
2270
+ "pytest",
2271
+ "uvicorn",
2272
+ "gunicorn",
2273
+ "flask",
2274
+ "django-admin",
2275
+ "rails",
2276
+ "bundle",
2277
+ "rake",
2278
+ "mix",
2279
+ "make",
2280
+ "cmake",
2281
+ "gradle",
2282
+ "mvn",
2283
+ "ant",
2284
+ "docker",
2285
+ "docker-compose",
2286
+ "kubectl",
2287
+ "helm",
2288
+ "terraform",
2289
+ "ansible",
2290
+ "ssh",
2291
+ "scp",
2292
+ "rsync",
2293
+ "sftp",
2294
+ "psql",
2295
+ "mysql",
2296
+ "mongo",
2297
+ "redis-cli",
2298
+ "sqlite3",
2299
+ "composer",
2300
+ "php",
2301
+ "ruby",
2302
+ "java",
2303
+ "javac",
2304
+ "deno",
2305
+ "dotnet",
2306
+ "msbuild",
2307
+ "tsc",
2308
+ "nx",
2309
+ "lerna",
2310
+ "eslint",
2311
+ "prettier",
2312
+ "ls",
2313
+ "cd",
2314
+ "pwd",
2315
+ "cat",
2316
+ "tail",
2317
+ "head",
2318
+ "less",
2319
+ "more",
2320
+ "grep",
2321
+ "rg",
2322
+ "fd",
2323
+ "find",
2324
+ "watch",
2325
+ "code",
2326
+ "open",
2327
+ "vim",
2328
+ "nvim",
2329
+ "tmux",
2330
+ "htop",
2331
+ "top",
2332
+ "brew",
2333
+ "tox",
2334
+ }
2335
+
2336
+ allowed_prefixes = (
2337
+ "./",
2338
+ "../",
2339
+ "~/",
2340
+ "bin/",
2341
+ "sbin/",
2342
+ )
2343
+
2344
+
2345
+ def clean_tokens(tokens):
2346
+ while tokens and tokens[0] in {"$", "#", "%"}:
2347
+ tokens = tokens[1:]
2348
+ while tokens and (tokens[0].endswith("$") or tokens[0].endswith("#") or tokens[0].endswith("%")):
2349
+ tokens = tokens[1:]
2350
+ return tokens
2351
+
2352
+
2353
+ def extract_prompt_and_command(raw_line):
2354
+ stripped = raw_line.strip()
2355
+ if not stripped:
2356
+ return "", ""
2357
+
2358
+ # Pattern 1: host:path:command (common bash/zsh prompts)
2359
+ if ":" in stripped:
2360
+ maybe_prompt, maybe_cmd = stripped.split(":", 1)
2361
+ maybe_prompt = maybe_prompt.strip().rstrip("#$%")
2362
+ maybe_cmd = maybe_cmd.strip()
2363
+ if maybe_prompt and maybe_cmd and prompt_pattern.match(maybe_prompt):
2364
+ prompt_lower = maybe_prompt.lower()
2365
+ if prompt_lower not in disallowed_prompts:
2366
+ return maybe_prompt, maybe_cmd
2367
+
2368
+ # Pattern 2: prompt ending with $, #, % (supports prompts without ':')
2369
+ match = prompt_sigil_pattern.match(stripped)
2370
+ if match:
2371
+ maybe_prompt = (match.group("prompt") or "").strip().rstrip("#$%")
2372
+ maybe_cmd = (match.group("cmd") or "").strip()
2373
+ if maybe_cmd:
2374
+ if maybe_prompt and maybe_prompt.lower() in disallowed_prompts:
2375
+ return "", ""
2376
+ return (maybe_prompt if maybe_prompt else match.group("sigil")), maybe_cmd
2377
+
2378
+ # Pattern 3: bare prompt lines like "$ git status"
2379
+ match = simple_sigil_pattern.match(stripped)
2380
+ if match:
2381
+ maybe_cmd = (match.group("cmd") or "").strip()
2382
+ if maybe_cmd:
2383
+ return match.group("sigil"), maybe_cmd
2384
+
2385
+ return "", ""
2386
+
2387
+
2388
+ for raw_line in lines:
2389
+ prompt_part, command_part = extract_prompt_and_command(raw_line)
2390
+ if not command_part:
2391
+ continue
2392
+
2393
+ tokens = clean_tokens(command_part.split())
2394
+ if not tokens:
2395
+ continue
2396
+
2397
+ first_token = tokens[0].lower()
2398
+ if first_token == "sudo" and len(tokens) > 1:
2399
+ first_token = tokens[1].lower()
2400
+
2401
+ if first_token in disallowed_first_tokens:
2402
+ continue
2403
+
2404
+ normalized_command = " ".join(tokens)
2405
+ formatted_line = f"{prompt_part}:{normalized_command}"
2406
+ fallback_candidates.append(formatted_line)
2407
+
2408
+ allowed = (
2409
+ first_token in common_commands
2410
+ or normalized_command.startswith(allowed_prefixes)
2411
+ or any(
2412
+ first_token.startswith(prefix)
2413
+ for prefix in (
2414
+ "git",
2415
+ "npm",
2416
+ "pnpm",
2417
+ "yarn",
2418
+ "node",
2419
+ "npx",
2420
+ "bun",
2421
+ "cargo",
2422
+ "python",
2423
+ "pip",
2424
+ "poetry",
2425
+ "pytest",
2426
+ "uvicorn",
2427
+ "docker",
2428
+ "kubectl",
2429
+ "helm",
2430
+ "terraform",
2431
+ "ansible",
2432
+ "ssh",
2433
+ "scp",
2434
+ "rsync",
2435
+ "rails",
2436
+ "bundle",
2437
+ "rake",
2438
+ "mix",
2439
+ "psql",
2440
+ "mysql",
2441
+ "mongo",
2442
+ "redis",
2443
+ )
2444
+ )
2445
+ )
2446
+
2447
+ if allowed:
2448
+ command_candidates.append(formatted_line)
2449
+
2450
+
2451
+ if history_commands:
2452
+ command_candidates = history_commands
2453
+ elif not command_candidates:
2454
+ command_candidates = fallback_candidates
2455
+
2456
+ extracted_commands = command_candidates
2457
+ history_lines = [f"{idx + 1}. {line}" for idx, line in enumerate(extracted_commands)]
2458
+ command_history = "\n".join(history_lines) if history_lines else "None detected."
2459
+ last_command_line = extracted_commands[-1] if extracted_commands else ""
2460
+ commands_detected = bool(extracted_commands)
2461
+
2462
+ # Capture the first 10 lines of output as fallback context (joined by newline)
2463
+ first_ten_lines = "\n".join(lines[:10]) if lines else ""
2464
+
2465
+ description_guidance = """Description rules:
2466
+ - Describe the primary activity in a short phrase (3-8 words).
2467
+ - Do NOT include any app/tool name or prefix in the output.
2468
+ - If git activity is visible, the description should reflect it (e.g., reviewing changes), but do not add 'git_'."""
2469
+
2470
+ metadata_summary = "\n".join(
2471
+ line
2472
+ for line in [
2473
+ f"pane_current_command: {pane_cmd}" if pane_cmd else "",
2474
+ f"window_name: {window_name}" if window_name else "",
2475
+ f"pane_title: {pane_title}" if pane_title else "",
2476
+ f"pane_mode: {pane_mode}" if pane_mode else "",
2477
+ f"alternate_screen_active: {alternate_on}",
2478
+ f"mouse_mode_active: {mouse_any_flag}",
2479
+ f"likely_tui_app: {is_tui}",
2480
+ ]
2481
+ if line
2482
+ )
2483
+
2484
+ prompt = f"""Analyze this terminal session and create a descriptive tmux session name based on what the user actually did.
2485
+
2486
+ Terminal output (last capture):
2487
+ {content}
2488
+
2489
+ Tmux metadata (use as supporting hints, not as the primary source of truth):
2490
+ {metadata_summary}
2491
+
2492
+ Extracted command history (oldest to newest):
2493
+ {command_history}
2494
+
2495
+ Most recent command (anchor for description and summary):
2496
+ {last_command_line if last_command_line else 'None detected'}
2497
+
2498
+ Focus: produce a concise description of the activity. Follow these rules:
2499
+ {description_guidance}
2500
+
2501
+ {'No clear prompt/command lines were detected. Rely on the terminal output context.' if not commands_detected else ''}
2502
+
2503
+ {'An interactive TUI is likely running. Prefer the visible screen content plus recent command history over generic app names.' if is_tui.lower() in {'1', 'true', 'yes', 'on'} else ''}
2504
+
2505
+ {'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 ''}
2506
+
2507
+ 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).
2508
+
2509
+ Instructions:
2510
+ 1. Produce a session name using lowercase letters, numbers, and underscores only.
2511
+ 2. Maximum length: 100 characters.
2512
+ 3. Do NOT include any app/tool name or prefix.
2513
+ 4. Describe the concrete task or state in a short phrase.
2514
+ 5. If git activity is visible anywhere, the description must reflect git activity.
2515
+ 6. Respond with ONLY the final session name, nothing else."""
2516
+
2517
+ if provider == "openai":
2518
+ request = {
2519
+ "model": openai_model,
2520
+ "max_tokens": 100,
2521
+ "temperature": 0.2,
2522
+ "messages": [
2523
+ {
2524
+ "role": "user",
2525
+ "content": prompt
2526
+ }
2527
+ ]
2528
+ }
2529
+ else:
2530
+ request = {
2531
+ "model": "claude-3-5-haiku-latest",
2532
+ "max_tokens": 100,
2533
+ "messages": [
2534
+ {
2535
+ "role": "user",
2536
+ "content": prompt
2537
+ }
2538
+ ]
2539
+ }
2540
+
2541
+ print(json.dumps(request))
2542
+ PYCODE
2543
+ )
2544
+ request_body="$request_payload"
2545
+ fi
2546
+ if [[ -z "$request_body" ]]; then
2547
+ # Fallback: base64 encode the content to avoid escaping issues
2548
+ local encoded_content
2549
+ local fallback_file
2550
+ if [[ "$OSTYPE" == "darwin"* ]]; then
2551
+ fallback_file=$(mktemp -t orchestra_fb)
2552
+ else
2553
+ fallback_file=$(mktemp)
2554
+ fi
2555
+ {
2556
+ printf '%b\n\n' "$metadata_block"
2557
+ cat "$temp_file"
2558
+ } > "$fallback_file"
2559
+ encoded_content=$(base64 < "$fallback_file" | tr -d '\n')
2560
+ rm -f "$fallback_file"
2561
+ if [[ "$provider" == "openai" ]]; then
2562
+ request_body=$(cat <<EOF
2563
+ {
2564
+ "model": "$openai_model",
2565
+ "max_tokens": 100,
2566
+ "temperature": 0.2,
2567
+ "messages": [
2568
+ {
2569
+ "role": "user",
2570
+ "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: Use terminal metadata only as supporting hints. Prioritize the visible terminal content and recent command history.\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, the description should reflect git activity (but do not add 'git_').\n\nDESCRIPTION ONLY: Return a short description of the activity.\n- Do NOT include the app/tool name or any prefix in the output.\n\nUse underscores only. Max 100 chars total.\n\nExamples:\n- reviewing_changes_before_commit\n- resolving_merge_conflicts\n- fixing_auth_bug\n- running_nextjs_dev_server\n\nBase64 terminal output:\n${encoded_content}\n\nRespond with ONLY the session name, nothing else."
2571
+ }
2572
+ ]
2573
+ }
2574
+ EOF
2575
+ )
2576
+ else
2577
+ request_body=$(cat <<EOF
2578
+ {
2579
+ "model": "claude-3-5-haiku-latest",
2580
+ "max_tokens": 100,
2581
+ "messages": [
2582
+ {
2583
+ "role": "user",
2584
+ "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: Use terminal metadata only as supporting hints. Prioritize the visible terminal content and recent command history.\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, the description should reflect git activity (but do not add 'git_').\n\nDESCRIPTION ONLY: Return a short description of the activity.\n- Do NOT include the app/tool name or any prefix in the output.\n\nUse underscores only. Max 100 chars total.\n\nExamples:\n- reviewing_changes_before_commit\n- resolving_merge_conflicts\n- fixing_auth_bug\n- running_nextjs_dev_server\n\nBase64 terminal output:\n${encoded_content}\n\nRespond with ONLY the session name, nothing else."
2585
+ }
2586
+ ]
2587
+ }
2588
+ EOF
2589
+ )
2590
+ fi
2591
+ fi
2592
+
2593
+ # Make the API call
2594
+ local response
2595
+ local new_name=""
2596
+ if [[ "$provider" == "openai" ]]; then
2597
+ # First attempt: OpenAI Responses API + Files API for structured app/description output.
2598
+ # If this path fails, we fall back to chat completions.
2599
+ local context_file=""
2600
+ if [[ "$OSTYPE" == "darwin"* ]]; then
2601
+ context_file=$(mktemp -t orchestra_ctx)
2602
+ else
2603
+ context_file=$(mktemp)
2604
+ fi
2605
+ {
2606
+ printf '%b\n\n' "$metadata_block"
2607
+ printf 'TMUX SESSION CAPTURE\n\n'
2608
+ cat "$temp_file"
2609
+ if [[ -n "$history_arg" && -f "$history_arg" ]]; then
2610
+ printf '\n\nRECENT COMMAND HISTORY\n\n'
2611
+ cat "$history_arg"
2612
+ fi
2613
+ } > "$context_file"
2614
+
2615
+ local file_upload_resp openai_file_id
2616
+ file_upload_resp=$(curl -s -X POST https://api.openai.com/v1/files \
2617
+ --max-time 20 \
2618
+ -H "Authorization: Bearer $OPENAI_API_KEY" \
2619
+ -F purpose="user_data" \
2620
+ -F "file=@$context_file;type=text/plain" 2>/dev/null)
2621
+ rm -f "$context_file"
2622
+
2623
+ openai_file_id=""
2624
+ if have_cmd python3; then
2625
+ openai_file_id="$(python3 -c 'import json,sys
2626
+ try:
2627
+ data = json.load(sys.stdin)
2628
+ except Exception:
2629
+ data = {}
2630
+ print(data.get("id", ""))' <<<"$file_upload_resp" 2>/dev/null || true)"
2631
+ fi
2632
+
2633
+ if [[ -n "$openai_file_id" ]]; then
2634
+ local responses_body responses_resp ai_desc
2635
+ responses_body=$(python3 - "$openai_model" "$openai_file_id" <<'PYCODE'
2636
+ import json
2637
+ import sys
2638
+
2639
+ model = sys.argv[1]
2640
+ file_id = sys.argv[2]
2641
+
2642
+ prompt = """Analyze the provided terminal session file and produce ONLY a short activity description.
2643
+
2644
+ Return STRICT JSON in this exact shape:
2645
+ {"description":"<short_snake_case_description>"}
2646
+
2647
+ Rules:
2648
+ - description: concise snake_case description of the activity
2649
+ - do NOT include app/tool name or any prefix in the description
2650
+ - do not include punctuation beyond underscores
2651
+ - keep it short and specific
2652
+ """
2653
+
2654
+ req = {
2655
+ "model": model,
2656
+ "temperature": 0.2,
2657
+ "max_output_tokens": 180,
2658
+ "input": [
2659
+ {
2660
+ "role": "user",
2661
+ "content": [
2662
+ {"type": "input_text", "text": prompt},
2663
+ {"type": "input_file", "file_id": file_id},
2664
+ ],
2665
+ }
2666
+ ],
2667
+ }
2668
+ print(json.dumps(req))
2669
+ PYCODE
2670
+ )
2671
+
2672
+ responses_resp=$(curl -s -X POST https://api.openai.com/v1/responses \
2673
+ --max-time 20 \
2674
+ -H "Content-Type: application/json" \
2675
+ -H "Authorization: Bearer $OPENAI_API_KEY" \
2676
+ -d "$responses_body" 2>/dev/null)
2677
+
2678
+ # Best-effort cleanup of uploaded file
2679
+ curl -s -X DELETE "https://api.openai.com/v1/files/$openai_file_id" \
2680
+ -H "Authorization: Bearer $OPENAI_API_KEY" >/dev/null 2>&1 || true
2681
+
2682
+ ai_desc="$(python3 -c 'import json
2683
+ import re
2684
+ import sys
2685
+
2686
+ def clean(value):
2687
+ value = (value or "").strip().lower()
2688
+ value = re.sub(r"[^a-z0-9_ -]+", "", value)
2689
+ value = value.replace(" ", "_")
2690
+ value = re.sub(r"_+", "_", value).strip("_")
2691
+ return value
2692
+
2693
+ try:
2694
+ data = json.load(sys.stdin)
2695
+ except Exception:
2696
+ print("")
2697
+ raise SystemExit
2698
+
2699
+ text = ""
2700
+ if isinstance(data.get("output_text"), str):
2701
+ text = data["output_text"]
2702
+
2703
+ if not text:
2704
+ for item in data.get("output", []):
2705
+ if not isinstance(item, dict):
2706
+ continue
2707
+ if item.get("type") != "message":
2708
+ continue
2709
+ for content in item.get("content", []):
2710
+ if not isinstance(content, dict):
2711
+ continue
2712
+ if isinstance(content.get("text"), str):
2713
+ text += content["text"]
2714
+
2715
+ obj = None
2716
+ if text:
2717
+ text = text.strip()
2718
+ try:
2719
+ obj = json.loads(text)
2720
+ except Exception:
2721
+ match = re.search(r"\{.*\}", text, re.S)
2722
+ if match:
2723
+ try:
2724
+ obj = json.loads(match.group(0))
2725
+ except Exception:
2726
+ obj = None
2727
+
2728
+ description = ""
2729
+ if isinstance(obj, dict):
2730
+ description = clean(str(obj.get("description", "")))
2731
+
2732
+ if not description and text:
2733
+ description = clean(text)
2734
+
2735
+ print(description)' <<<"$responses_resp" 2>/dev/null || true)"
2736
+
2737
+ if [[ -n "$ai_desc" ]]; then
2738
+ new_name="$ai_desc"
2739
+ fi
2740
+ fi
2741
+
2742
+ if [[ -z "$new_name" ]]; then
2743
+ # Fallback: existing chat completions path
2744
+ response=$(curl -s -X POST https://api.openai.com/v1/chat/completions \
2745
+ --max-time 20 \
2746
+ -H "Content-Type: application/json" \
2747
+ -H "Authorization: Bearer $OPENAI_API_KEY" \
2748
+ -d "$request_body" 2>&1) || {
2749
+ err "Failed to call OpenAI API: $response"
2750
+ return 1
2751
+ }
2752
+
2753
+ if echo "$response" | grep -q '"error"'; then
2754
+ 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")
2755
+ err "API error: $error_msg"
2756
+ return 1
2757
+ fi
2758
+
2759
+ new_name=$(echo "$response" | python3 -c "import sys, json; data = json.load(sys.stdin); print(data.get('choices', [{}])[0].get('message', {}).get('content', ''))" 2>/dev/null) || {
2760
+ new_name=$(echo "$response" | grep -o '"content":"[^"]*"' | head -1 | sed 's/"content":"\([^"]*\)"/\1/')
2761
+ }
2762
+ fi
2763
+ else
2764
+ response=$(curl -s -X POST https://api.anthropic.com/v1/messages \
2765
+ --max-time 20 \
2766
+ -H "Content-Type: application/json" \
2767
+ -H "x-api-key: $ANTHROPIC_API_KEY" \
2768
+ -H "anthropic-version: 2023-06-01" \
2769
+ -d "$request_body" 2>&1) || {
2770
+ err "Failed to call Anthropic API: $response"
2771
+ return 1
2772
+ }
2773
+
2774
+ if echo "$response" | grep -q '"error"'; then
2775
+ 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")
2776
+ err "API error: $error_msg"
2777
+ return 1
2778
+ fi
2779
+
2780
+ new_name=$(echo "$response" | python3 -c "import sys, json; data = json.load(sys.stdin); print(data.get('content', [{}])[0].get('text', ''))" 2>/dev/null) || {
2781
+ new_name=$(echo "$response" | grep -o '"text":"[^"]*"' | head -1 | sed 's/"text":"\([^"]*\)"/\1/')
2782
+ }
2783
+ fi
2784
+
2785
+ # Clean up temp files
2786
+ rm -f "$temp_file"
2787
+ if [[ -n "$history_temp" ]]; then
2788
+ rm -f "$history_temp"
2789
+ fi
2790
+
2791
+ # Clean up the AI description (remove any spaces, special chars except underscore and dash)
2792
+ new_name=$(echo "$new_name" | tr ' ' '_' | sed 's/[^a-zA-Z0-9_-]//g' | cut -c1-100)
2793
+ if [[ -z "$new_name" ]]; then
2794
+ err "Failed to generate name from AI response"
2795
+ return 1
2796
+ fi
2797
+
2798
+ new_name="${new_name:0:100}"
2799
+
2800
+ echo "$new_name"
2801
+ }
2802
+
2803
+ # Display tmux message
2804
+ # Usage: tmux_display_message <message> [duration_ms]
2805
+ tmux_display_message() {
2806
+ local message="$1"
2807
+ local duration="${2:-3000}"
2808
+
2809
+ if tmux_available && tmux_inside_session; then
2810
+ tmux display-message -d "$duration" "$message"
2811
+ fi
2812
+ }