iterm2_ruby 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,690 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ITerm2
6
+ class Client
7
+ def initialize(app_name: "iterm2_ruby")
8
+ @connection = Connection.new(app_name: app_name)
9
+ @subscribers = {}
10
+ @subscriber_mutex = Mutex.new
11
+ end
12
+
13
+ def close
14
+ unsubscribe_all
15
+ @connection.close
16
+ end
17
+
18
+ # --- Topology ---
19
+
20
+ def list_sessions
21
+ request(:list_sessions_request, Proto::ListSessionsRequest.new).list_sessions_response
22
+ end
23
+
24
+ # Returns a flat array of {window_id:, tab_id:, session_id:, title:} hashes
25
+ def topology
26
+ resp = list_sessions
27
+ sessions = []
28
+
29
+ resp.windows.each do |window|
30
+ window.tabs.each do |tab|
31
+ extract_sessions(tab.root, sessions, window_id: window.window_id, tab_id: tab.tab_id)
32
+ end
33
+ end
34
+
35
+ sessions
36
+ end
37
+
38
+ def windows
39
+ topology.map { |s| s[:window_id] }.uniq.map { |id| Window.new(client: self, id: id) }
40
+ end
41
+
42
+ def tabs(window_id: nil)
43
+ rows = topology
44
+ rows = rows.select { |s| s[:window_id] == window_id } if window_id
45
+ rows.map { |s| [s[:window_id], s[:tab_id]] }.uniq.map do |wid, tid|
46
+ Tab.new(client: self, id: tid, window_id: wid)
47
+ end
48
+ end
49
+
50
+ def sessions(window_id: nil, tab_id: nil)
51
+ rows = topology
52
+ rows = rows.select { |s| s[:window_id] == window_id } if window_id
53
+ rows = rows.select { |s| s[:tab_id] == tab_id } if tab_id
54
+ rows.map { |s| Session.new(client: self, id: s[:session_id], window_id: s[:window_id], tab_id: s[:tab_id], title: s[:title]) }
55
+ end
56
+
57
+ # --- Session Interaction ---
58
+
59
+ def send_text(session_id, text, suppress_broadcast: false)
60
+ response = request(:send_text_request, Proto::SendTextRequest.new(
61
+ session: session_id,
62
+ text: text,
63
+ suppress_broadcast: suppress_broadcast
64
+ ))
65
+ response.send_text_response.status == :OK
66
+ end
67
+
68
+ def read_screen(session_id, trailing_lines: nil)
69
+ line_range = if trailing_lines
70
+ Proto::LineRange.new(trailing_lines: trailing_lines)
71
+ else
72
+ Proto::LineRange.new(screen_contents_only: true)
73
+ end
74
+
75
+ response = request(:get_buffer_request, Proto::GetBufferRequest.new(
76
+ session: session_id,
77
+ line_range: line_range
78
+ ))
79
+ buf = response.get_buffer_response
80
+ raise RPCError, "GetBuffer failed: #{buf.status}" unless buf.status == :OK
81
+
82
+ {
83
+ lines: buf.contents.map(&:text),
84
+ cursor: buf.cursor ? { x: buf.cursor.x, y: buf.cursor.y } : nil
85
+ }
86
+ end
87
+
88
+ # --- Activate (raise) ---
89
+
90
+ def activate_session(session_id, select_tab: true, order_window_front: true)
91
+ response = request(:activate_request, Proto::ActivateRequest.new(
92
+ session_id: session_id,
93
+ select_tab: select_tab,
94
+ select_session: true,
95
+ order_window_front: order_window_front,
96
+ activate_app: Proto::ActivateRequest::App.new(raise_all_windows: false, ignoring_other_apps: true)
97
+ ))
98
+ response.activate_response.status == :OK
99
+ end
100
+
101
+ def activate_tab(tab_id, order_window_front: true)
102
+ response = request(:activate_request, Proto::ActivateRequest.new(
103
+ tab_id: tab_id,
104
+ order_window_front: order_window_front,
105
+ activate_app: Proto::ActivateRequest::App.new(raise_all_windows: false, ignoring_other_apps: true)
106
+ ))
107
+ response.activate_response.status == :OK
108
+ end
109
+
110
+ def activate_window(window_id)
111
+ response = request(:activate_request, Proto::ActivateRequest.new(
112
+ window_id: window_id,
113
+ order_window_front: true,
114
+ activate_app: Proto::ActivateRequest::App.new(raise_all_windows: false, ignoring_other_apps: true)
115
+ ))
116
+ response.activate_response.status == :OK
117
+ end
118
+
119
+ # Raise first session whose title matches pattern
120
+ def raise_by_title(pattern)
121
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
122
+ match = topology.find { |s| s[:title]&.match?(regex) }
123
+ raise NotFoundError, "No session matching #{pattern.inspect}" unless match
124
+
125
+ activate_session(match[:session_id])
126
+ end
127
+
128
+ # --- CreateTab ---
129
+
130
+ def create_tab(window_id: nil, profile_name: nil)
131
+ req = Proto::CreateTabRequest.new
132
+ req.window_id = window_id if window_id
133
+ req.profile_name = profile_name if profile_name
134
+
135
+ resp = request(:create_tab_request, req).create_tab_response
136
+ raise RPCError, "CreateTab failed: #{resp.status}" unless resp.status == :OK
137
+
138
+ { window_id: resp.window_id, tab_id: resp.tab_id, session_id: resp.session_id }
139
+ end
140
+
141
+ # --- SplitPane ---
142
+
143
+ def split_pane(session_id, vertical: true, profile_name: nil, profile_customizations: {})
144
+ props = profile_customizations.map do |key, value|
145
+ Proto::ProfileProperty.new(key: key, json_value: JSON.dump(value))
146
+ end
147
+
148
+ split_req = Proto::SplitPaneRequest.new(
149
+ split_direction: vertical ? :VERTICAL : :HORIZONTAL,
150
+ before: false
151
+ )
152
+ split_req.session = session_id if session_id
153
+ split_req.profile_name = profile_name if profile_name
154
+ split_req.custom_profile_properties.replace(props) unless props.empty?
155
+
156
+ resp = request(:split_pane_request, split_req).split_pane_response
157
+ raise RPCError, "SplitPane failed: #{resp.status}" unless resp.status == :OK
158
+
159
+ resp.session_id.first
160
+ end
161
+
162
+ # --- ReorderTabs ---
163
+
164
+ def reorder_tabs(assignments)
165
+ protos = assignments.map do |window_id, tab_ids|
166
+ Proto::ReorderTabsRequest::Assignment.new(window_id: window_id, tab_ids: tab_ids)
167
+ end
168
+
169
+ resp = request(:reorder_tabs_request, Proto::ReorderTabsRequest.new(assignments: protos)).reorder_tabs_response
170
+ raise RPCError, "ReorderTabs failed: #{resp.status}" unless resp.status == :OK
171
+
172
+ true
173
+ end
174
+
175
+ # --- Close ---
176
+
177
+ def close_session(session_id, force: false)
178
+ response = request(:close_request, Proto::CloseRequest.new(
179
+ sessions: Proto::CloseRequest::CloseSessions.new(session_ids: [session_id]),
180
+ force: force
181
+ ))
182
+ response.close_response.statuses.first == :OK
183
+ end
184
+
185
+ def close_tab(tab_id, force: false)
186
+ response = request(:close_request, Proto::CloseRequest.new(
187
+ tabs: Proto::CloseRequest::CloseTabs.new(tab_ids: [tab_id]),
188
+ force: force
189
+ ))
190
+ response.close_response.statuses.first == :OK
191
+ end
192
+
193
+ # --- SetProfileProperty ---
194
+
195
+ def set_profile_property(session_id, key, value)
196
+ assignment = Proto::SetProfilePropertyRequest::Assignment.new(
197
+ key: key,
198
+ json_value: JSON.dump(value)
199
+ )
200
+
201
+ response = request(:set_profile_property_request, Proto::SetProfilePropertyRequest.new(
202
+ session: session_id,
203
+ assignments: [assignment]
204
+ ))
205
+ response.set_profile_property_response.status == :OK
206
+ end
207
+
208
+ # --- Variables ---
209
+
210
+ # Get one or more variables from a session, tab, window, or app scope
211
+ # Use "*" to get all variables as a hash
212
+ def get_variables(*names, session_id: nil, tab_id: nil, window_id: nil, app: nil)
213
+ req = Proto::VariableRequest.new(get: names)
214
+ set_variable_scope!(req, session_id: session_id, tab_id: tab_id, window_id: window_id, app: app)
215
+
216
+ resp = request(:variable_request, req).variable_response
217
+ raise RPCError, "GetVariables failed: #{resp.status}" unless resp.status == :OK
218
+
219
+ if names == ["*"]
220
+ JSON.parse(resp.values.first || "{}")
221
+ elsif names.size == 1
222
+ val = resp.values.first
223
+ val == "null" ? nil : JSON.parse(val)
224
+ else
225
+ names.zip(resp.values).to_h { |name, val| [name, val == "null" ? nil : JSON.parse(val)] }
226
+ end
227
+ end
228
+
229
+ # Set user-defined variables (must begin with "user.")
230
+ def set_variables(vars, session_id: nil, tab_id: nil, window_id: nil, app: nil)
231
+ sets = vars.map { |k, v| Proto::VariableRequest::Set.new(name: k, value: JSON.dump(v)) }
232
+ req = Proto::VariableRequest.new(set: sets)
233
+ set_variable_scope!(req, session_id: session_id, tab_id: tab_id, window_id: window_id, app: app)
234
+
235
+ request(:variable_request, req).variable_response.status == :OK
236
+ end
237
+
238
+ # Convenience: get a single variable
239
+ def get_variable(name, **scope)
240
+ get_variables(name, **scope)
241
+ end
242
+
243
+ # Convenience: get session's tty, pid, cwd, name in one call
244
+ def session_info(session_id)
245
+ vars = get_variables("tty", "pid", "path", "name", "jobName", session_id: session_id)
246
+ {
247
+ tty: vars["tty"],
248
+ pid: vars["pid"],
249
+ cwd: vars["path"],
250
+ name: vars["name"],
251
+ job: vars["jobName"]
252
+ }
253
+ end
254
+
255
+ # Enriched topology with variables (cwd, pid, tty, job)
256
+ def topology_enriched
257
+ sessions = topology
258
+ sessions.each do |s|
259
+ info = session_info(s[:session_id])
260
+ s.merge!(info)
261
+ end
262
+ sessions
263
+ end
264
+
265
+ # Raise first session whose cwd matches pattern
266
+ def raise_by_cwd(pattern)
267
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
268
+ match = topology_enriched.find { |s| s[:cwd]&.match?(regex) }
269
+ raise NotFoundError, "No session with cwd matching #{pattern.inspect}" unless match
270
+
271
+ activate_session(match[:session_id])
272
+ end
273
+
274
+ # JXA-compatible topology: returns hash matching get-iterm-topology.js output format.
275
+ # Maps iTerm session GUIDs to Claude session IDs via session-iterm-mapping.json.
276
+ # Used by claude_code_history's SessionAggregator as a drop-in replacement.
277
+ def topology_for_aggregator(mapping_file: nil)
278
+ mapping_file ||= File.expand_path("~/.claude/session-iterm-mapping.json")
279
+ tty_to_claude = build_tty_mapping(mapping_file)
280
+
281
+ resp = list_sessions
282
+ tab_index = 0
283
+ windows = resp.windows.map.with_index(1) do |window, widx|
284
+ tabs = window.tabs.map do |tab|
285
+ sessions_in_tab = extract_sessions_flat(tab.root)
286
+ # Use first session in tab (primary session)
287
+ primary = sessions_in_tab.first
288
+ next nil unless primary
289
+
290
+ tab_index += 1
291
+ info = session_info(primary[:session_id])
292
+ claude_id = tty_to_claude[info[:tty]]
293
+
294
+ # Parse title for project_name, session_number, status
295
+ title = info[:name] || primary[:title] || ""
296
+ project_name, session_number, status = parse_tab_title(title)
297
+
298
+ {
299
+ "tab_index" => tab_index,
300
+ "title" => title,
301
+ "project_name" => project_name,
302
+ "session_number" => session_number,
303
+ "status" => status,
304
+ "tty" => info[:tty],
305
+ "cwd" => info[:cwd],
306
+ "session_id" => claude_id,
307
+ "foreground_process" => info[:job],
308
+ "iterm_session_id" => primary[:session_id],
309
+ "iterm_tab_id" => tab.tab_id,
310
+ "pid" => info[:pid]
311
+ }
312
+ end.compact
313
+
314
+ {
315
+ "window_index" => widx,
316
+ "window_id" => window.window_id,
317
+ "name" => "Window #{widx}",
318
+ "tabs" => tabs
319
+ }
320
+ end
321
+
322
+ { "windows" => windows }
323
+ end
324
+
325
+ # --- GetProfileProperty ---
326
+
327
+ def get_profile_property(session_id, *keys)
328
+ req = Proto::GetProfilePropertyRequest.new(session: session_id)
329
+ req.keys.replace(keys) unless keys.empty?
330
+
331
+ resp = request(:get_profile_property_request, req).get_profile_property_response
332
+ raise RPCError, "GetProfileProperty failed: #{resp.status}" unless resp.status == :OK
333
+
334
+ resp.properties.to_h { |p| [p.key, JSON.parse(p.json_value)] }
335
+ end
336
+
337
+ # --- ListProfiles ---
338
+
339
+ def list_profiles(properties: nil, guids: nil)
340
+ req = Proto::ListProfilesRequest.new
341
+ req.properties.replace(properties) if properties
342
+ req.guids.replace(guids) if guids
343
+
344
+ resp = request(:list_profiles_request, req).list_profiles_response
345
+ resp.profiles.map do |profile|
346
+ profile.properties.to_h { |p| [p.key, JSON.parse(p.json_value)] }
347
+ end
348
+ end
349
+
350
+ # --- Inject ---
351
+
352
+ def inject(session_id, data)
353
+ resp = request(:inject_request, Proto::InjectRequest.new(
354
+ session_id: [session_id],
355
+ data: data.encode("BINARY")
356
+ )).inject_response
357
+ resp.status.first == :OK
358
+ end
359
+
360
+ # --- Focus ---
361
+
362
+ def focus
363
+ parse_focus_response(request(:focus_request, Proto::FocusRequest.new).focus_response)
364
+ end
365
+
366
+ # --- GetPrompt ---
367
+
368
+ def get_prompt(session_id)
369
+ resp = request(:get_prompt_request, Proto::GetPromptRequest.new(session: session_id)).get_prompt_response
370
+
371
+ case resp.status
372
+ when :OK
373
+ {
374
+ state: resp.prompt_state.to_s.downcase.to_sym,
375
+ command: resp.command.empty? ? nil : resp.command,
376
+ working_directory: resp.working_directory.empty? ? nil : resp.working_directory,
377
+ exit_status: resp.exit_status
378
+ }
379
+ when :PROMPT_UNAVAILABLE
380
+ { state: :unavailable, command: nil, working_directory: nil, exit_status: nil }
381
+ else
382
+ raise RPCError, "GetPrompt failed: #{resp.status}"
383
+ end
384
+ end
385
+
386
+ # --- GetProperty ---
387
+
388
+ def get_property(name, session_id: nil, window_id: nil)
389
+ req = Proto::GetPropertyRequest.new(name: name)
390
+ req.session_id = session_id if session_id
391
+ req.window_id = window_id if window_id
392
+
393
+ resp = request(:get_property_request, req).get_property_response
394
+ raise RPCError, "GetProperty failed: #{resp.status}" unless resp.status == :OK
395
+
396
+ JSON.parse(resp.json_value)
397
+ end
398
+
399
+ # --- Notifications ---
400
+
401
+ def subscribe(notification_type, session_id: nil, &callback)
402
+ ensure_dispatch_loop!
403
+
404
+ req = Proto::NotificationRequest.new(
405
+ subscribe: true,
406
+ notification_type: notification_type
407
+ )
408
+ req.session = session_id if session_id
409
+
410
+ resp = request(:notification_request, req).notification_response
411
+ unless resp.status == :OK || resp.status == :ALREADY_SUBSCRIBED
412
+ raise SubscriptionError, "Subscribe failed: #{resp.status}"
413
+ end
414
+
415
+ key = [session_id, notification_type]
416
+ token = [key, callback]
417
+ @subscriber_mutex.synchronize do
418
+ (@subscribers[key] ||= []) << callback
419
+ end
420
+
421
+ token
422
+ end
423
+
424
+ def unsubscribe(token)
425
+ key, callback = token
426
+ session_id, notification_type = key
427
+
428
+ @subscriber_mutex.synchronize do
429
+ list = @subscribers[key]
430
+ list&.delete(callback)
431
+ @subscribers.delete(key) if list&.empty?
432
+ end
433
+
434
+ req = Proto::NotificationRequest.new(
435
+ subscribe: false,
436
+ notification_type: notification_type
437
+ )
438
+ req.session = session_id if session_id
439
+
440
+ request(:notification_request, req)
441
+ rescue IOError, ConnectionError
442
+ # Connection may already be closed during shutdown
443
+ end
444
+
445
+ def on_focus_change(&block)
446
+ subscribe(:NOTIFY_ON_FOCUS_CHANGE) do |n|
447
+ block.call(parse_focus_notification(n))
448
+ end
449
+ end
450
+
451
+ def on_new_session(&block)
452
+ subscribe(:NOTIFY_ON_NEW_SESSION) do |n|
453
+ block.call({ type: :new_session, session_id: n.new_session_notification.session_id })
454
+ end
455
+ end
456
+
457
+ def on_session_terminated(&block)
458
+ subscribe(:NOTIFY_ON_TERMINATE_SESSION) do |n|
459
+ block.call({ type: :session_terminated, session_id: n.terminate_session_notification.session_id })
460
+ end
461
+ end
462
+
463
+ def on_prompt_change(session_id, &block)
464
+ subscribe(:NOTIFY_ON_PROMPT, session_id: session_id) do |n|
465
+ block.call(parse_prompt_notification(n))
466
+ end
467
+ end
468
+
469
+ def on_screen_update(session_id, &block)
470
+ subscribe(:NOTIFY_ON_SCREEN_UPDATE, session_id: session_id) do |n|
471
+ block.call({ type: :screen_update, session: n.screen_update_notification.session })
472
+ end
473
+ end
474
+
475
+ def on_layout_change(&block)
476
+ subscribe(:NOTIFY_ON_LAYOUT_CHANGE) do |n|
477
+ block.call({ type: :layout_change })
478
+ end
479
+ end
480
+
481
+ private
482
+
483
+ def next_id
484
+ @connection.next_id
485
+ end
486
+
487
+ def rpc!(envelope)
488
+ response = @connection.rpc(envelope)
489
+ if response.submessage == :error
490
+ raise RPCError, "Server error: #{response.error}"
491
+ end
492
+ response
493
+ end
494
+
495
+ def request(field, message)
496
+ envelope = Proto::ClientOriginatedMessage.new(id: next_id, field => message)
497
+ rpc!(envelope)
498
+ end
499
+
500
+ def set_variable_scope!(req, session_id: nil, tab_id: nil, window_id: nil, app: nil)
501
+ if session_id
502
+ req.session_id = session_id
503
+ elsif tab_id
504
+ req.tab_id = tab_id
505
+ elsif window_id
506
+ req.window_id = window_id
507
+ elsif app
508
+ req.app = true
509
+ else
510
+ raise ArgumentError, "Must specify session_id:, tab_id:, window_id:, or app: true"
511
+ end
512
+ end
513
+
514
+ def extract_sessions(node, sessions, window_id:, tab_id:)
515
+ return unless node
516
+
517
+ node.links.each do |link|
518
+ case link.child
519
+ when :session
520
+ s = link.session
521
+ sessions << {
522
+ window_id: window_id,
523
+ tab_id: tab_id,
524
+ session_id: s.unique_identifier,
525
+ title: s.title
526
+ }
527
+ when :node
528
+ extract_sessions(link.node, sessions, window_id: window_id, tab_id: tab_id)
529
+ end
530
+ end
531
+ end
532
+
533
+ # Flat extraction without window/tab context (for topology_for_aggregator)
534
+ def extract_sessions_flat(node, sessions = [])
535
+ return sessions unless node
536
+
537
+ node.links.each do |link|
538
+ case link.child
539
+ when :session
540
+ s = link.session
541
+ sessions << { session_id: s.unique_identifier, title: s.title }
542
+ when :node
543
+ extract_sessions_flat(link.node, sessions)
544
+ end
545
+ end
546
+ sessions
547
+ end
548
+
549
+ # Build tty → Claude session ID mapping from session-iterm-mapping.json
550
+ def build_tty_mapping(mapping_file)
551
+ return {} unless File.exist?(mapping_file)
552
+
553
+ mapping = JSON.parse(File.read(mapping_file))
554
+ tty_map = {}
555
+ mapping.each do |claude_id, info|
556
+ tty = info["tty"]
557
+ tty_map[tty] = claude_id if tty && tty != "not a tty"
558
+ end
559
+ tty_map
560
+ rescue JSON::ParserError
561
+ {}
562
+ end
563
+
564
+ def ensure_dispatch_loop!
565
+ return if @connection.dispatch_active?
566
+
567
+ @connection.on_notification { |notification| dispatch_notification(notification) }
568
+ @connection.start_dispatch_loop!
569
+ end
570
+
571
+ def dispatch_notification(notification)
572
+ type = notification_type_for(notification)
573
+ session_id = notification_session_for(notification)
574
+
575
+ # Try session-specific subscribers first, then global
576
+ callbacks = @subscriber_mutex.synchronize do
577
+ specific = @subscribers[[session_id, type]] || []
578
+ global = @subscribers[[nil, type]] || []
579
+ specific + global
580
+ end
581
+
582
+ callbacks.each do |cb|
583
+ cb.call(notification)
584
+ rescue => e
585
+ $stderr.puts "Notification callback error: #{e.message}"
586
+ end
587
+ end
588
+
589
+ NOTIFICATION_FIELDS = {
590
+ focus_changed_notification: :NOTIFY_ON_FOCUS_CHANGE,
591
+ new_session_notification: :NOTIFY_ON_NEW_SESSION,
592
+ terminate_session_notification: :NOTIFY_ON_TERMINATE_SESSION,
593
+ prompt_notification: :NOTIFY_ON_PROMPT,
594
+ screen_update_notification: :NOTIFY_ON_SCREEN_UPDATE,
595
+ layout_changed_notification: :NOTIFY_ON_LAYOUT_CHANGE,
596
+ keystroke_notification: :NOTIFY_ON_KEYSTROKE
597
+ }.freeze
598
+
599
+ def notification_type_for(notification)
600
+ NOTIFICATION_FIELDS.each do |field, type|
601
+ return type if notification.send("has_#{field}?")
602
+ end
603
+ nil
604
+ end
605
+
606
+ def notification_session_for(notification)
607
+ if notification.has_prompt_notification?
608
+ notification.prompt_notification.session
609
+ elsif notification.has_screen_update_notification?
610
+ notification.screen_update_notification.session
611
+ elsif notification.has_new_session_notification?
612
+ notification.new_session_notification.session_id
613
+ elsif notification.has_terminate_session_notification?
614
+ notification.terminate_session_notification.session_id
615
+ end
616
+ end
617
+
618
+ def parse_focus_notification(notification)
619
+ n = notification.focus_changed_notification
620
+ result = { type: :focus }
621
+ result[:app_active] = n.application_active if n.has_application_active?
622
+
623
+ if n.has_window?
624
+ w = n.window
625
+ result[:window] = w.window_id
626
+ result[:window_status] = w.window_status.to_s.downcase.to_sym
627
+ end
628
+
629
+ result[:selected_tab] = n.selected_tab unless n.selected_tab.empty?
630
+ result[:session] = n.session unless n.session.empty?
631
+ result
632
+ end
633
+
634
+ def parse_prompt_notification(notification)
635
+ n = notification.prompt_notification
636
+ result = { type: :prompt, session: n.session }
637
+
638
+ if n.has_prompt?
639
+ result[:state] = :prompt
640
+ result[:unique_prompt_id] = n.unique_prompt_id unless n.unique_prompt_id.empty?
641
+ elsif n.has_command_start?
642
+ result[:state] = :command_start
643
+ result[:command] = n.command_start.command unless n.command_start.command.empty?
644
+ elsif n.has_command_end?
645
+ result[:state] = :command_end
646
+ result[:exit_status] = n.command_end.status
647
+ end
648
+
649
+ result
650
+ end
651
+
652
+ def unsubscribe_all
653
+ tokens = @subscriber_mutex.synchronize do
654
+ @subscribers.flat_map do |key, callbacks|
655
+ callbacks.map { |cb| [key, cb] }
656
+ end
657
+ end
658
+
659
+ tokens.each { |token| unsubscribe(token) }
660
+ end
661
+
662
+ def parse_focus_response(resp)
663
+ result = { active_session: nil, active_tab: nil, active_window: nil, app_active: false }
664
+
665
+ resp.notifications.each do |n|
666
+ result[:app_active] = n.application_active if n.has_application_active?
667
+
668
+ if n.has_window?
669
+ w = n.window
670
+ result[:active_window] = w.window_id if w.window_status == :TERMINAL_WINDOW_BECAME_KEY
671
+ end
672
+
673
+ result[:active_tab] = n.selected_tab unless n.selected_tab.empty?
674
+ result[:active_session] = n.session unless n.session.empty?
675
+ end
676
+
677
+ result
678
+ end
679
+
680
+ # Parse title like "cultiv-os #1 [WAITING]" into [project_name, session_number, status]
681
+ def parse_tab_title(title)
682
+ # Match: "project_name #N [STATUS]" or "project_name #N" or just title
683
+ if title =~ /\A(.+?)\s+#(\d+)\s*(?:\[(\w+)\])?\s*\z/
684
+ [$1, $2.to_i, ($3 || "working").downcase]
685
+ else
686
+ [title, nil, nil]
687
+ end
688
+ end
689
+ end
690
+ end