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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +265 -0
- data/Rakefile +7 -0
- data/bin/iterm2ctl +620 -0
- data/docs/api.md +523 -0
- data/docs/architecture.md +91 -0
- data/docs/cli.md +257 -0
- data/iterm2_ruby.gemspec +29 -0
- data/lib/iterm2/client.rb +690 -0
- data/lib/iterm2/connection.rb +267 -0
- data/lib/iterm2/proto/api_pb.rb +233 -0
- data/lib/iterm2/session.rb +44 -0
- data/lib/iterm2/tab.rb +39 -0
- data/lib/iterm2/version.rb +5 -0
- data/lib/iterm2/window.rb +33 -0
- data/lib/iterm2.rb +106 -0
- data/llms.txt +114 -0
- data/proto/api.proto +1642 -0
- metadata +82 -0
|
@@ -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
|