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.
data/docs/api.md ADDED
@@ -0,0 +1,523 @@
1
+ # API Reference
2
+
3
+ Complete reference for the `iterm2_ruby` Ruby API.
4
+
5
+ ## Connection
6
+
7
+ ### `ITerm2.connect(app_name: "iterm2_ruby") { |client| ... }` -> Client
8
+
9
+ Opens a connection, yields the client, auto-closes when the block returns. Without a block, returns an open client (you must call `close` yourself).
10
+
11
+ ```ruby
12
+ # Block form (preferred)
13
+ ITerm2.connect do |client|
14
+ puts client.topology.size
15
+ end
16
+
17
+ # Manual form
18
+ client = ITerm2.connect
19
+ client.topology
20
+ client.close
21
+ ```
22
+
23
+ ### `ITerm2::Client.new(app_name: "iterm2_ruby")` -> Client
24
+
25
+ Creates a persistent client with an open connection. Equivalent to `ITerm2.connect` without a block.
26
+
27
+ ### `client.close` -> nil
28
+
29
+ Closes the connection. Unsubscribes all notification listeners and stops the dispatch thread if running.
30
+
31
+ ---
32
+
33
+ ## Topology
34
+
35
+ ### `client.list_sessions` -> ListSessionsResponse
36
+
37
+ Returns the raw protobuf `ListSessionsResponse` from iTerm2. Includes the full window/tab/session tree with split pane hierarchy.
38
+
39
+ ### `client.topology` -> Array\<Hash\>
40
+
41
+ Flattened session list. Each hash:
42
+
43
+ ```ruby
44
+ {
45
+ window_id: String, # e.g. "pty-0B3C..."
46
+ tab_id: String, # e.g. "4A7D..."
47
+ session_id: String, # e.g. "E8F2..."
48
+ title: String # e.g. "~/work/myproject"
49
+ }
50
+ ```
51
+
52
+ ### `client.topology_enriched` -> Array\<Hash\>
53
+
54
+ Same as `topology` but with additional keys from `session_info`:
55
+
56
+ ```ruby
57
+ {
58
+ window_id:, tab_id:, session_id:, title:,
59
+ tty: String, # e.g. "/dev/ttys042"
60
+ pid: Integer, # e.g. 12345
61
+ cwd: String, # e.g. "/Users/you/work/project"
62
+ name: String, # session name
63
+ job: String # e.g. "ruby"
64
+ }
65
+ ```
66
+
67
+ **Note:** Makes one `get_variables` RPC per session. For many sessions, this can be slow.
68
+
69
+ ### `client.session_info(session_id)` -> Hash
70
+
71
+ ```ruby
72
+ {
73
+ tty: String | nil, # "/dev/ttys042"
74
+ pid: Integer | nil, # 12345
75
+ cwd: String | nil, # "/Users/you/work/project"
76
+ name: String | nil, # session name
77
+ job: String | nil # foreground process name
78
+ }
79
+ ```
80
+
81
+ ### `client.topology_for_aggregator(mapping_file: nil)` -> Hash
82
+
83
+ JXA-compatible topology for `claude_code_history`'s SessionAggregator. Maps iTerm session GUIDs to Claude session IDs via `~/.claude/session-iterm-mapping.json`.
84
+
85
+ Returns nested `{ "windows" => [{ "tabs" => [...] }] }` matching the format from `get-iterm-topology.js`.
86
+
87
+ ---
88
+
89
+ ## Session Interaction
90
+
91
+ ### `client.send_text(session_id, text, suppress_broadcast: false)` -> true | false
92
+
93
+ Sends text to a session as if typed. Returns `true` on success.
94
+
95
+ - **session_id** (String) -- target session identifier
96
+ - **text** (String) -- text to send
97
+ - **suppress_broadcast** (Boolean) -- if `true`, don't broadcast to other sessions in broadcast group
98
+
99
+ **Gotcha:** Does NOT auto-append `\n`. To execute a command, include `"\n"`:
100
+
101
+ ```ruby
102
+ client.send_text(sid, "ls -la\n")
103
+ ```
104
+
105
+ ### `client.read_screen(session_id, trailing_lines: nil)` -> Hash
106
+
107
+ Reads visible screen contents (or scrollback).
108
+
109
+ - **session_id** (String) -- target session
110
+ - **trailing_lines** (Integer | nil) -- number of trailing scrollback lines. `nil` = visible screen only.
111
+
112
+ Returns:
113
+
114
+ ```ruby
115
+ {
116
+ lines: Array<String>, # each line of text
117
+ cursor: { x: Integer, y: Integer } | nil
118
+ }
119
+ ```
120
+
121
+ **Raises:** `RPCError` if the buffer request fails.
122
+
123
+ ### `client.inject(session_id, data)` -> true | false
124
+
125
+ Injects data into a session as if it came from the running process (not as user input). The data appears in the terminal output without being sent to the shell.
126
+
127
+ - **session_id** (String) -- target session
128
+ - **data** (String) -- raw bytes to inject (will be encoded as BINARY)
129
+
130
+ ---
131
+
132
+ ## Window Management
133
+
134
+ ### `client.activate_session(session_id, select_tab: true, order_window_front: true)` -> true | false
135
+
136
+ Raises and focuses a session. Selects its tab and brings the window to front by default.
137
+
138
+ ### `client.activate_tab(tab_id, order_window_front: true)` -> true | false
139
+
140
+ Raises a tab by ID.
141
+
142
+ ### `client.activate_window(window_id)` -> true | false
143
+
144
+ Raises a window by ID. Always orders window to front.
145
+
146
+ ### `client.raise_by_title(pattern)` -> true | false
147
+
148
+ Finds the first session whose title matches `pattern` (case-insensitive regex) and activates it.
149
+
150
+ **Raises:** `NotFoundError` if no session matches.
151
+
152
+ ```ruby
153
+ client.raise_by_title("my-project") # substring match
154
+ client.raise_by_title("^claude") # regex anchor
155
+ ```
156
+
157
+ ### `client.raise_by_cwd(pattern)` -> true | false
158
+
159
+ Finds the first session whose working directory matches `pattern` (case-insensitive regex) and activates it.
160
+
161
+ **Raises:** `NotFoundError` if no session matches.
162
+
163
+ **Note:** Calls `topology_enriched` internally, so it makes one `get_variables` RPC per session.
164
+
165
+ ### `client.create_tab(window_id: nil, profile_name: nil)` -> Hash
166
+
167
+ Creates a new tab (or window if `window_id` is nil and iTerm2 decides to).
168
+
169
+ - **window_id** (String | nil) -- create tab in this window, or new window if nil
170
+ - **profile_name** (String | nil) -- use this profile
171
+
172
+ Returns:
173
+
174
+ ```ruby
175
+ { window_id: String, tab_id: String, session_id: String }
176
+ ```
177
+
178
+ **Raises:** `RPCError` on failure.
179
+
180
+ ### `client.split_pane(session_id, vertical: true, profile_name: nil, profile_customizations: {})` -> String
181
+
182
+ Splits a pane. Returns the new session ID.
183
+
184
+ - **session_id** (String) -- session to split
185
+ - **vertical** (Boolean) -- `true` for vertical split, `false` for horizontal
186
+ - **profile_name** (String | nil) -- profile for the new pane
187
+ - **profile_customizations** (Hash) -- profile property overrides (key => value)
188
+
189
+ **Raises:** `RPCError` on failure.
190
+
191
+ ### `client.close_session(session_id, force: false)` -> true | false
192
+
193
+ Closes a session. With `force: true`, skips the confirmation prompt.
194
+
195
+ **Gotcha:** iTerm2 does NOT fire `NOTIFY_ON_TERMINATE_SESSION` for API-initiated closes.
196
+
197
+ ### `client.close_tab(tab_id, force: false)` -> true | false
198
+
199
+ Closes an entire tab. With `force: true`, skips the confirmation prompt.
200
+
201
+ ### `client.reorder_tabs(assignments)` -> true
202
+
203
+ Moves and reorders tabs across windows. `assignments` is a Hash of `{ window_id => [tab_ids] }`. Each entry defines the complete tab order for that window. A tab can be moved from one window to another by including it in the target window's list.
204
+
205
+ ```ruby
206
+ # Move tab_b to window_1, placing it after tab_a
207
+ client.reorder_tabs("window_1" => ["tab_a", "tab_b"])
208
+ ```
209
+
210
+ **Raises:** `RPCError` with status `INVALID_ASSIGNMENT` (duplicate tab ID), `INVALID_WINDOW_ID`, or `INVALID_TAB_ID`.
211
+
212
+ ---
213
+
214
+ ## Profile & Properties
215
+
216
+ ### `client.set_profile_property(session_id, key, value)` -> true | false
217
+
218
+ Sets a profile property on a session. The value is JSON-encoded automatically.
219
+
220
+ ```ruby
221
+ client.set_profile_property(sid, "Background Color", { "Red Component" => 0.1, ... })
222
+ client.set_profile_property(sid, "Name", "My Custom Profile")
223
+ ```
224
+
225
+ ### `client.get_profile_property(session_id, *keys)` -> Hash
226
+
227
+ Gets profile properties. Returns a hash of `{ key => value }`. With no keys, returns all properties.
228
+
229
+ ```ruby
230
+ client.get_profile_property(sid, "Name", "Guid")
231
+ # => {"Name" => "Default", "Guid" => "ABC-123"}
232
+ ```
233
+
234
+ **Raises:** `RPCError` on failure.
235
+
236
+ ### `client.list_profiles(properties: nil, guids: nil)` -> Array\<Hash\>
237
+
238
+ Lists all iTerm2 profiles. Each profile is a hash of `{ key => value }`.
239
+
240
+ - **properties** (Array\<String\> | nil) -- only include these property keys. `nil` = all.
241
+ - **guids** (Array\<String\> | nil) -- only include profiles with these GUIDs
242
+
243
+ ```ruby
244
+ client.list_profiles(properties: ["Name", "Guid"])
245
+ # => [{"Name" => "Default", "Guid" => "..."}, ...]
246
+ ```
247
+
248
+ ### `client.get_property(name, session_id: nil, window_id: nil)` -> Object
249
+
250
+ Gets a named property from a session or window. Returns the JSON-decoded value.
251
+
252
+ ```ruby
253
+ client.get_property("columns", session_id: sid) # => 120
254
+ client.get_property("frame", window_id: wid) # => {"origin" => {...}, "size" => {...}}
255
+ ```
256
+
257
+ **Raises:** `RPCError` on failure.
258
+
259
+ ### `client.get_variables(*names, session_id: nil, tab_id: nil, window_id: nil, app: nil)` -> varies
260
+
261
+ Gets variables from a scope (session, tab, window, or app).
262
+
263
+ - With `"*"` -- returns Hash of all variables
264
+ - With one name -- returns the value directly
265
+ - With multiple names -- returns Hash of `{ name => value }`
266
+
267
+ Scope is required -- pass exactly one of `session_id:`, `tab_id:`, `window_id:`, or `app: true`.
268
+
269
+ ```ruby
270
+ client.get_variables("path", session_id: sid) # => "/Users/you/work"
271
+ client.get_variables("*", app: true) # => {"effectiveTheme" => "dark", ...}
272
+ client.get_variables("tty", "pid", session_id: sid) # => {"tty" => "/dev/ttys042", "pid" => 123}
273
+ ```
274
+
275
+ **Raises:** `RPCError` on failure, `ArgumentError` if no scope given.
276
+
277
+ ### `client.set_variables(vars, session_id: nil, tab_id: nil, window_id: nil, app: nil)` -> true | false
278
+
279
+ Sets user-defined variables. Variable names must begin with `"user."`.
280
+
281
+ ```ruby
282
+ client.set_variables({ "user.project" => "myapp" }, session_id: sid)
283
+ ```
284
+
285
+ ### `client.get_variable(name, **scope)` -> Object
286
+
287
+ Convenience wrapper for `get_variables` with a single name.
288
+
289
+ ### `client.focus` -> Hash
290
+
291
+ Returns the current focus state:
292
+
293
+ ```ruby
294
+ {
295
+ active_session: String | nil, # currently focused session ID
296
+ active_tab: String | nil, # currently selected tab ID
297
+ active_window: String | nil, # key window ID
298
+ app_active: Boolean # whether iTerm2 is the frontmost app
299
+ }
300
+ ```
301
+
302
+ ### `client.get_prompt(session_id)` -> Hash
303
+
304
+ Gets the shell prompt state for a session (requires shell integration).
305
+
306
+ ```ruby
307
+ {
308
+ state: Symbol, # :editing, :running, :at_prompt, :unavailable
309
+ command: String | nil, # last/current command
310
+ working_directory: String | nil, # shell's cwd
311
+ exit_status: Integer | nil # last command's exit code
312
+ }
313
+ ```
314
+
315
+ **Gotcha:** Returns `{ state: :unavailable, ... }` for sessions without iTerm2 shell integration installed.
316
+
317
+ ---
318
+
319
+ ## Notifications
320
+
321
+ Notifications use a background dispatch loop. The first call to any `subscribe` or `on_*` method starts the dispatch thread automatically.
322
+
323
+ ### Lifecycle
324
+
325
+ 1. Call `on_*` or `subscribe` -- dispatch thread starts
326
+ 2. Events arrive and fire callbacks on the dispatch thread
327
+ 3. Call `client.close` -- unsubscribes all, stops dispatch thread
328
+
329
+ ### `client.subscribe(notification_type, session_id: nil, &callback)` -> token
330
+
331
+ Low-level subscribe. `notification_type` is a symbol like `:NOTIFY_ON_FOCUS_CHANGE`. Returns a token for `unsubscribe`.
332
+
333
+ - **session_id** (String | nil) -- scope to a specific session, or `nil` for global
334
+
335
+ **Raises:** `SubscriptionError` on failure.
336
+
337
+ ### `client.unsubscribe(token)` -> nil
338
+
339
+ Unsubscribes a previously created subscription.
340
+
341
+ ### `client.on_focus_change { |event| }` -> token
342
+
343
+ Fires when focus changes (session, tab, window, or app activation).
344
+
345
+ ```ruby
346
+ # event shape:
347
+ {
348
+ type: :focus,
349
+ app_active: Boolean, # optional
350
+ window: String, # optional, window ID
351
+ window_status: Symbol, # optional, :terminal_window_became_key, etc.
352
+ selected_tab: String, # optional, tab ID
353
+ session: String # optional, session ID
354
+ }
355
+ ```
356
+
357
+ ### `client.on_new_session { |event| }` -> token
358
+
359
+ Fires when a new session is created.
360
+
361
+ ```ruby
362
+ { type: :new_session, session_id: String }
363
+ ```
364
+
365
+ ### `client.on_session_terminated { |event| }` -> token
366
+
367
+ Fires when a session terminates.
368
+
369
+ ```ruby
370
+ { type: :session_terminated, session_id: String }
371
+ ```
372
+
373
+ **Gotcha:** Does NOT fire for sessions closed via the API (`close_session`/`close_tab`). Only fires for user-initiated closes or process exits.
374
+
375
+ ### `client.on_prompt_change(session_id) { |event| }` -> token
376
+
377
+ Fires when prompt state changes in a session (requires shell integration).
378
+
379
+ ```ruby
380
+ # event shapes (varies by state):
381
+ { type: :prompt, session: String, state: :prompt, unique_prompt_id: String }
382
+ { type: :prompt, session: String, state: :command_start, command: String }
383
+ { type: :prompt, session: String, state: :command_end, exit_status: Integer }
384
+ ```
385
+
386
+ ### `client.on_screen_update(session_id) { |event| }` -> token
387
+
388
+ Fires when screen content changes in a session.
389
+
390
+ ```ruby
391
+ { type: :screen_update, session: String }
392
+ ```
393
+
394
+ ### `client.on_layout_change { |event| }` -> token
395
+
396
+ Fires when window/tab layout changes (splits, tab reorder, etc.).
397
+
398
+ ```ruby
399
+ { type: :layout_change }
400
+ ```
401
+
402
+ **Gotcha:** iTerm2 sends duplicate notification events (each event arrives twice). This is server-side behavior, not a bug.
403
+
404
+ ---
405
+
406
+ ## One-Shot Module Methods
407
+
408
+ These class methods on `ITerm2` open a connection, run the command, and close. Convenient for single operations but inefficient for multiple calls (each opens a new connection).
409
+
410
+ | One-shot method | Equivalent |
411
+ |---|---|
412
+ | `ITerm2.topology` | `client.topology` |
413
+ | `ITerm2.topology_enriched` | `client.topology_enriched` |
414
+ | `ITerm2.list_sessions` | `client.list_sessions` |
415
+ | `ITerm2.session_info(sid)` | `client.session_info(sid)` |
416
+ | `ITerm2.send_text(sid, text)` | `client.send_text(sid, text)` |
417
+ | `ITerm2.read_screen(sid)` | `client.read_screen(sid)` |
418
+ | `ITerm2.inject(sid, data)` | `client.inject(sid, data)` |
419
+ | `ITerm2.activate_session(sid)` | `client.activate_session(sid)` |
420
+ | `ITerm2.raise_by_title(pat)` | `client.raise_by_title(pat)` |
421
+ | `ITerm2.raise_by_cwd(pat)` | `client.raise_by_cwd(pat)` |
422
+ | `ITerm2.focus` | `client.focus` |
423
+ | `ITerm2.get_prompt(sid)` | `client.get_prompt(sid)` |
424
+ | `ITerm2.get_variable(name, **scope)` | `client.get_variable(name, **scope)` |
425
+ | `ITerm2.get_profile_property(sid, *keys)` | `client.get_profile_property(sid, *keys)` |
426
+ | `ITerm2.list_profiles(...)` | `client.list_profiles(...)` |
427
+
428
+ **Note:** Notifications (`on_*`) are NOT available in one-shot mode. They require a persistent client.
429
+
430
+ ---
431
+
432
+ ## Common Patterns
433
+
434
+ ### Get active session and read its screen
435
+
436
+ ```ruby
437
+ ITerm2.connect do |client|
438
+ active = client.focus[:active_session]
439
+ screen = client.read_screen(active)
440
+ puts screen[:lines].join("\n")
441
+ end
442
+ ```
443
+
444
+ ### Find session by title and send a command
445
+
446
+ ```ruby
447
+ ITerm2.connect do |client|
448
+ match = client.topology.find { |s| s[:title] =~ /my-project/i }
449
+ client.send_text(match[:session_id], "make test\n") if match
450
+ end
451
+ ```
452
+
453
+ ### Monitor sessions for changes
454
+
455
+ ```ruby
456
+ ITerm2.connect do |client|
457
+ client.on_new_session { |e| puts "New: #{e[:session_id]}" }
458
+ client.on_session_terminated { |e| puts "Gone: #{e[:session_id]}" }
459
+ client.on_focus_change { |e| puts "Focus: #{e}" }
460
+ sleep
461
+ end
462
+ ```
463
+
464
+ ### Batch query all session details
465
+
466
+ ```ruby
467
+ ITerm2.connect do |client|
468
+ client.topology_enriched.each do |s|
469
+ puts "#{s[:session_id]} | #{s[:title]} | #{s[:cwd]} | #{s[:job]}"
470
+ end
471
+ end
472
+ ```
473
+
474
+ ### Wait for a command to finish (shell integration required)
475
+
476
+ ```ruby
477
+ ITerm2.connect do |client|
478
+ sid = client.focus[:active_session]
479
+ client.send_text(sid, "sleep 3\n")
480
+
481
+ done = Queue.new
482
+ token = client.on_prompt_change(sid) do |e|
483
+ done.push(e) if e[:state] == :command_end
484
+ end
485
+
486
+ result = done.pop
487
+ puts "Exit status: #{result[:exit_status]}"
488
+ client.unsubscribe(token)
489
+ end
490
+ ```
491
+
492
+ ---
493
+
494
+ ## Error Classes
495
+
496
+ All errors inherit from `ITerm2::Error`.
497
+
498
+ | Class | When |
499
+ |---|---|
500
+ | `ITerm2::Error` | Base class |
501
+ | `ITerm2::ConnectionError` | Can't connect to iTerm2 (not running, API disabled) |
502
+ | `ITerm2::AuthError` | osascript authentication failed |
503
+ | `ITerm2::RPCError` | iTerm2 returned an error status for an RPC |
504
+ | `ITerm2::NotFoundError` | `raise_by_title`/`raise_by_cwd` found no match (subclass of RPCError) |
505
+ | `ITerm2::SubscriptionError` | Notification subscription failed |
506
+
507
+ ---
508
+
509
+ ## Gotchas
510
+
511
+ 1. **`send_text` does not append `\n`** -- you must include it to execute a command. The CLI auto-appends it.
512
+
513
+ 2. **Duplicate notifications** -- iTerm2 sends each notification event twice. This is server-side behavior.
514
+
515
+ 3. **One-shot mode opens a new connection per call** -- use `ITerm2.connect { |c| ... }` for multiple operations.
516
+
517
+ 4. **`close_session` doesn't fire termination notifications** -- iTerm2 only fires `NOTIFY_ON_TERMINATE_SESSION` for user-initiated closes and process exits.
518
+
519
+ 5. **`get_prompt` returns `:unavailable`** -- sessions without iTerm2 shell integration return `{ state: :unavailable }`.
520
+
521
+ 6. **`topology_enriched` is slow with many sessions** -- makes one RPC per session. Use plain `topology` when you only need IDs and titles.
522
+
523
+ 7. **Variable scope is required** -- `get_variables`/`set_variables` raise `ArgumentError` if you don't specify `session_id:`, `tab_id:`, `window_id:`, or `app: true`.
@@ -0,0 +1,91 @@
1
+ # Architecture
2
+
3
+ How `iterm2_ruby` connects to and communicates with iTerm2.
4
+
5
+ ## Connection Lifecycle
6
+
7
+ ```
8
+ osascript (one-time auth)
9
+ |
10
+ v
11
+ Unix socket (~/.../iTerm2/private/socket) --or--> TCP localhost:1912
12
+ |
13
+ v
14
+ WebSocket upgrade (RFC 6455, subprotocol: api.iterm2.com)
15
+ |
16
+ v
17
+ Protobuf RPC over binary WebSocket frames
18
+ ```
19
+
20
+ 1. **Authentication**: A single `osascript` call asks iTerm2 for a cookie and key pair. This triggers an iTerm2 confirmation dialog on first use for each `app_name`.
21
+
22
+ 2. **Socket**: Prefers the Unix domain socket at `~/Library/Application Support/iTerm2/private/socket`. Falls back to TCP on `127.0.0.1:1912` if the socket file doesn't exist.
23
+
24
+ 3. **WebSocket handshake**: Standard HTTP upgrade with custom iTerm2 headers (`x-iterm2-cookie`, `x-iterm2-key`, `x-iterm2-library-version`, `x-iterm2-advisory-name`). The framing is hand-rolled per RFC 6455 -- no external WebSocket gem.
25
+
26
+ 4. **Ready**: After the `101 Switching Protocols` response, the connection is ready for protobuf RPCs.
27
+
28
+ ## Protobuf Protocol
29
+
30
+ All messages use Google Protocol Buffers, defined in iTerm2's [`api.proto`](https://github.com/gnachman/iTerm2/blob/master/proto/api.proto).
31
+
32
+ - **Client -> Server**: `ClientOriginatedMessage` with an `id` field and a oneof request field (e.g., `list_sessions_request`, `send_text_request`)
33
+ - **Server -> Client**: `ServerOriginatedMessage` with a matching `id` and a oneof response field, OR a `notification` field for async events
34
+
35
+ Ruby bindings are generated with `protoc --ruby_out` from `api.proto`. The generated module is `Iterm2` (lowercase t), aliased as `ITerm2::Proto` in `lib/iterm2.rb`.
36
+
37
+ ## Sync vs Dispatch Mode
38
+
39
+ The connection has two operating modes:
40
+
41
+ ### Sync Mode (default)
42
+
43
+ Simple request-response. `rpc_sync` sends a frame and blocks reading the next frame as the response. Used when no notifications are active.
44
+
45
+ ```
46
+ Thread: send(request) --> recv(response) --> return
47
+ ```
48
+
49
+ ### Dispatch Mode (activated by first notification subscription)
50
+
51
+ A background reader thread runs continuously, routing incoming frames:
52
+
53
+ ```
54
+ Main thread: send(request) --> wait on Queue --> return response
55
+ Reader thread: recv(frame) --> RPC response? --> push to request's Queue
56
+ --> notification? --> fire subscriber callbacks
57
+ ```
58
+
59
+ The dispatch loop starts automatically on the first `subscribe` or `on_*` call. It stops when `client.close` is called.
60
+
61
+ ## Threading Model
62
+
63
+ | Mutex | Protects |
64
+ |---|---|
65
+ | `@mutex` | `@pending_responses` hash (request ID -> Queue) and `@id_counter` |
66
+ | `@write_mutex` | Socket writes (only one thread writes at a time) |
67
+ | `@subscriber_mutex` | `@subscribers` hash (callbacks for each notification type) |
68
+
69
+ The reader thread (`dispatch_loop`) is the only thread that reads from the socket in dispatch mode. RPC responses are delivered to the calling thread via a per-request `Queue`.
70
+
71
+ ## Notification Dispatch
72
+
73
+ Subscribers are keyed by `[session_id, notification_type]`:
74
+
75
+ - **Session-specific**: `[session_id, :NOTIFY_ON_PROMPT]` -- only fires for that session
76
+ - **Global**: `[nil, :NOTIFY_ON_FOCUS_CHANGE]` -- fires for all events of that type
77
+
78
+ When a notification arrives, the dispatch loop checks session-specific subscribers first, then global. Both are called if both exist.
79
+
80
+ Notification type detection uses `has_*?` methods on the protobuf `Notification` message (e.g., `has_focus_changed_notification?`) rather than checking `submessage`, because the protobuf oneof accessor is more reliable.
81
+
82
+ ## File Layout
83
+
84
+ ```
85
+ lib/iterm2.rb # Entry point, ITerm2.connect, one-shot methods, error classes
86
+ lib/iterm2/version.rb # VERSION constant
87
+ lib/iterm2/connection.rb # WebSocket + auth + sync/dispatch RPC
88
+ lib/iterm2/client.rb # High-level API (all public methods)
89
+ lib/iterm2/proto/api_pb.rb # protoc-generated bindings from api.proto
90
+ bin/iterm2ctl # CLI entry point
91
+ ```