antigravity-sdk 0.3.0 → 0.5.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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ module Antigravity
8
+ module Connection
9
+ # Manages the full lifecycle of communicating with the localharness Go binary.
10
+ #
11
+ # Lifecycle: discover → spawn → stdio handshake → WebSocket → events → shutdown
12
+ #
13
+ # The localharness binary is the core Go engine that powers Antigravity.
14
+ # It handles model calls, tool orchestration, and agent logic.
15
+ # This class is the Ruby adapter that speaks its protocol.
16
+ class LocalConnection < Base
17
+ # Known locations for the localharness binary (checked in order).
18
+ # IMPORTANT: The standalone `localharness` binary (from PyPI wheel) uses
19
+ # the stdin/stdout protobuf handshake. The `language_server` binary
20
+ # (Antigravity.app) does NOT — it's the IDE server mode.
21
+ BINARY_SEARCH_PATHS = [
22
+ File.expand_path('~/.antigravity/bin/localharness'),
23
+ File.expand_path('~/.local/bin/localharness'),
24
+ ].freeze
25
+
26
+ HARNESS_SUBCOMMAND = 'localharness'
27
+
28
+ attr_reader :port, :api_key, :pid
29
+
30
+ def initialize(binary_path: nil)
31
+ @binary_path = binary_path || self.class.find_binary!
32
+ @port = nil
33
+ @api_key = nil
34
+ @pid = nil
35
+ @stdin = nil
36
+ @stdout = nil
37
+ @stderr = nil
38
+ @connected = false
39
+ end
40
+
41
+ # --- Binary Discovery ---
42
+
43
+ # Finds the localharness binary. Search order:
44
+ # 1. ANTIGRAVITY_HARNESS_PATH env var
45
+ # 2. Antigravity.app (macOS)
46
+ # 3. ~/.antigravity/bin/
47
+ # 4. PATH lookup via `which`
48
+ # 5. Auto-download from PyPI (unless ANTIGRAVITY_AUTO_DOWNLOAD=false)
49
+ #
50
+ # @return [String] absolute path to the binary
51
+ # @raise [HarnessNotFoundError] if not found anywhere
52
+ def self.find_binary!
53
+ # 1. Env var override
54
+ if (env_path = ENV['ANTIGRAVITY_HARNESS_PATH'])
55
+ return env_path if File.executable?(env_path)
56
+
57
+ raise HarnessNotFoundError,
58
+ "ANTIGRAVITY_HARNESS_PATH=#{env_path} is not executable"
59
+ end
60
+
61
+ # 2. Known paths
62
+ BINARY_SEARCH_PATHS.each do |path|
63
+ return path if File.executable?(path)
64
+ end
65
+
66
+ # 3. PATH lookup
67
+ which_result = `which localharness 2>/dev/null`.strip
68
+ return which_result unless which_result.empty?
69
+
70
+ # 4. Auto-download from PyPI wheel (opt-out with ANTIGRAVITY_AUTO_DOWNLOAD=false)
71
+ auto_dl = ENV.fetch('ANTIGRAVITY_AUTO_DOWNLOAD', 'true').downcase
72
+ unless %w[false 0 no].include?(auto_dl)
73
+ $stderr.puts "⏳ localharness not found locally. Downloading from PyPI... this may take a minute."
74
+ return BinaryFetcher.fetch!
75
+ end
76
+
77
+ raise HarnessNotFoundError,
78
+ "Could not find localharness binary. Install Antigravity.app, set ANTIGRAVITY_HARNESS_PATH, or allow auto-download."
79
+ end
80
+
81
+ # --- Connection Lifecycle ---
82
+
83
+ def connect!
84
+ spawn_process!
85
+ perform_handshake!
86
+ connect_websocket!
87
+ @connected = true
88
+ self
89
+ end
90
+
91
+ def connected?
92
+ @connected && process_alive? && @ws_client&.connected?
93
+ end
94
+
95
+ def disconnect!
96
+ @connected = false
97
+ @ws_client&.close
98
+ kill_process!
99
+ end
100
+
101
+ # Expose the WebSocket client for Conversation to use
102
+ def ws_client
103
+ @ws_client
104
+ end
105
+
106
+ private
107
+
108
+ def spawn_process!
109
+ # Only pass 'localharness' subcommand when binary is language_server
110
+ # (Antigravity.app). The standalone localharness binary needs no subcommand.
111
+ cmd = if @binary_path.end_with?('language_server')
112
+ [@binary_path, HARNESS_SUBCOMMAND]
113
+ else
114
+ [@binary_path]
115
+ end
116
+
117
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*cmd)
118
+ @pid = @wait_thread.pid
119
+ rescue Errno::ENOENT => e
120
+ raise HarnessNotFoundError, "Failed to spawn localharness: #{e.message}"
121
+ end
122
+
123
+ def perform_handshake!
124
+ storage_dir = File.join(Dir.tmpdir, "antigravity-ruby-#{$$}")
125
+ FileUtils.mkdir_p(storage_dir)
126
+
127
+ # Send InputConfig via stdin
128
+ input_config = Protocol.encode_input_config(
129
+ storage_directory: storage_dir,
130
+ bind_address: 'localhost'
131
+ )
132
+ @stdin.write(input_config)
133
+ @stdin.flush
134
+
135
+ # Read OutputConfig from stdout
136
+ frame = Protocol.read_length_prefixed(@stdout, timeout: Antigravity.config.timeout_handshake)
137
+ output = Protocol.decode_output_config(frame)
138
+ @port = output[:port]
139
+ @api_key = output[:api_key]
140
+
141
+ raise HarnessHandshakeError, "Harness returned port=0" if @port == 0
142
+ rescue IOError, Errno::EPIPE, ProtocolError => e
143
+ # Capture stderr for diagnostics
144
+ stderr_output = begin
145
+ @stderr&.read_nonblock(4096)
146
+ rescue EOFError, IOError, Errno::EAGAIN
147
+ nil
148
+ end
149
+ kill_process!
150
+ detail = stderr_output ? " Stderr: #{stderr_output.strip}" : ''
151
+ raise HarnessHandshakeError, "Stdio handshake failed: #{e.message}#{detail}"
152
+ end
153
+
154
+ def connect_websocket!
155
+ @ws_client = WebSocketClient.new(port: @port, api_key: @api_key)
156
+ @ws_client.connect!
157
+ rescue => e
158
+ kill_process!
159
+ raise ProtocolError, "WebSocket connection failed: #{e.message}"
160
+ end
161
+
162
+ def process_alive?
163
+ return false unless @pid
164
+
165
+ Process.kill(0, @pid)
166
+ true
167
+ rescue Errno::ESRCH, Errno::EPERM
168
+ false
169
+ end
170
+
171
+ def kill_process!
172
+ [@stdin, @stdout, @stderr].each { |io| io&.close rescue nil }
173
+ Process.kill('TERM', @pid) if @pid && process_alive?
174
+ @wait_thread&.join(5)
175
+ Process.kill('KILL', @pid) if @pid && process_alive?
176
+ rescue Errno::ESRCH, Errno::EPERM
177
+ # Already dead, fine
178
+ ensure
179
+ @pid = nil
180
+ @stdin = @stdout = @stderr = nil
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'websocket'
5
+ require 'json'
6
+ require 'securerandom'
7
+
8
+ module Antigravity
9
+ module Connection
10
+ # Lightweight WebSocket client for the localharness.
11
+ # Uses the `websocket` gem for frame encoding/decoding over raw TCPSocket.
12
+ # No EventMachine, no threads-by-default — just blocking IO.
13
+ class WebSocketClient
14
+ attr_reader :port, :api_key, :connected
15
+
16
+ def initialize(port:, api_key:)
17
+ @port = port
18
+ @api_key = api_key
19
+ @socket = nil
20
+ @handshake = nil
21
+ @connected = false
22
+ @frame_buffer = WebSocket::Frame::Incoming::Client.new
23
+ end
24
+
25
+ # Open the WebSocket connection to ws://localhost:<port>/
26
+ def connect!
27
+ @socket = TCPSocket.new('127.0.0.1', @port)
28
+
29
+ # Build and send the HTTP upgrade handshake
30
+ @handshake = WebSocket::Handshake::Client.new(
31
+ url: "ws://127.0.0.1:#{@port}/",
32
+ headers: { 'x-goog-api-key' => @api_key }
33
+ )
34
+ @socket.write(@handshake.to_s)
35
+ @socket.flush
36
+
37
+ # Read the server's handshake response
38
+ loop do
39
+ line = @socket.gets
40
+ raise ProtocolError, 'EOF during WebSocket handshake' unless line
41
+
42
+ @handshake << line
43
+ break if @handshake.finished?
44
+ end
45
+
46
+ unless @handshake.valid?
47
+ raise ProtocolError, "WebSocket handshake rejected: #{@handshake.error}"
48
+ end
49
+
50
+ @connected = true
51
+ self
52
+ end
53
+
54
+ def connected?
55
+ @connected && @socket && !@socket.closed?
56
+ end
57
+
58
+ # Send a JSON message as a WebSocket text frame
59
+ def send_json(data)
60
+ json = data.is_a?(String) ? data : JSON.generate(data)
61
+ frame = WebSocket::Frame::Outgoing::Client.new(
62
+ data: json,
63
+ type: :text,
64
+ version: @handshake.version
65
+ )
66
+ @socket.write(frame.to_s)
67
+ @socket.flush
68
+ end
69
+
70
+ # Read the next JSON message. Blocks until a text frame arrives.
71
+ # Yields each message if a block is given (for streaming).
72
+ # Returns nil on connection close or idle_timeout.
73
+ def receive_json(timeout: Antigravity.config.timeout_llm, idle_timeout: nil)
74
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
75
+ last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+
77
+ loop do
78
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+ remaining = deadline - now
80
+ raise ProtocolError, 'WebSocket read timeout' if remaining <= 0
81
+
82
+ if idle_timeout && (now - last_activity) >= idle_timeout
83
+ return nil
84
+ end
85
+
86
+ select_time = [remaining, 0.5].min
87
+ if idle_timeout
88
+ idle_rem = idle_timeout - (now - last_activity)
89
+ select_time = [select_time, idle_rem].min if idle_rem > 0
90
+ end
91
+
92
+ ready = IO.select([@socket], nil, nil, [select_time, 0.05].max)
93
+ next unless ready
94
+
95
+ data = @socket.read_nonblock(16384, exception: false)
96
+ case data
97
+ when :wait_readable then next
98
+ when nil
99
+ @connected = false
100
+ return nil
101
+ end
102
+
103
+ last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC)
104
+ @frame_buffer << data
105
+
106
+ while (frame = @frame_buffer.next)
107
+ case frame.type
108
+ when :text
109
+ return JSON.parse(frame.data, symbolize_names: true)
110
+ when :ping
111
+ pong = WebSocket::Frame::Outgoing::Client.new(
112
+ data: frame.data, type: :pong, version: @handshake.version
113
+ )
114
+ @socket.write(pong.to_s)
115
+ when :close
116
+ @connected = false
117
+ return nil
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ # Read messages in a loop, yielding each parsed JSON.
124
+ # Stops when block returns :stop, connection closes, or timeout.
125
+ def each_message(timeout: Antigravity.config.timeout_llm, &block)
126
+ loop do
127
+ # Block can return [:stop] or [:idle_timeout, seconds]
128
+ msg = receive_json(timeout: timeout, idle_timeout: @current_idle_timeout)
129
+ break unless msg
130
+
131
+ result = block.call(msg)
132
+ if result.is_a?(Array) && result.first == :idle_timeout
133
+ @current_idle_timeout = result.last
134
+ elsif result == :stop
135
+ @current_idle_timeout = nil
136
+ break
137
+ end
138
+ end
139
+ ensure
140
+ @current_idle_timeout = nil
141
+ end
142
+
143
+ def close
144
+ return unless @socket && !@socket.closed?
145
+
146
+ begin
147
+ close_frame = WebSocket::Frame::Outgoing::Client.new(
148
+ data: '', type: :close, version: @handshake&.version || 13
149
+ )
150
+ @socket.write(close_frame.to_s)
151
+ rescue IOError, Errno::EPIPE
152
+ # Already closed
153
+ end
154
+ @socket.close rescue nil
155
+ @connected = false
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module Antigravity
7
+ # Manages a multi-turn conversation with the harness over WebSocket.
8
+ # Handles the event loop: send InputEvent, receive OutputEvents, dispatch tools.
9
+ #
10
+ # Mirrors Python SDK's Conversation class:
11
+ # - history, turn_count, conversation_id
12
+ # - last_turn_usage, total_usage
13
+ class Conversation
14
+ attr_reader :conversation_id, :history, :turn_count, :total_usage, :last_turn_usage
15
+
16
+ def initialize(ws_client:, tool_runner: nil, hooks: nil)
17
+ @ws = ws_client
18
+ @tool_runner = tool_runner || ToolRunner.new
19
+ @hooks = hooks
20
+ @conversation_id = nil
21
+ @history = []
22
+ @turn_count = 0
23
+ @total_usage = empty_usage
24
+ @last_turn_usage = nil
25
+ @initialized = false
26
+ end
27
+
28
+ # Initialize the conversation session with the harness.
29
+ # Sends InitializeConversationEvent (protobuf JSON format), receives response.
30
+ def initialize_session!(harness_config:)
31
+ # harness_config IS the InitializeConversationEvent JSON:
32
+ # { config: { models: [...], workspaces: [...], ... } }
33
+ @ws.send_json(harness_config)
34
+
35
+ # Wait for InitializeConversationResponse
36
+ msg = @ws.receive_json(timeout: 30)
37
+ raise ProtocolError, 'No response to InitializeConversationEvent' unless msg
38
+
39
+ if (init_resp = msg[:initializeConversationResponse])
40
+ @conversation_id = init_resp[:cascadeId]
41
+ @initialized = true
42
+ else
43
+ # Some harness versions may wrap differently
44
+ @conversation_id = msg[:cascadeId] || msg.dig(:config, :cascadeId) || SecureRandom.uuid
45
+ @initialized = true
46
+ end
47
+
48
+ self
49
+ end
50
+
51
+ # Send a user message and collect the full response.
52
+ # Yields streaming chunks if a block is given.
53
+ #
54
+ # @param prompt [String] user message
55
+ # @return [Message] the complete response
56
+ def chat(prompt, timeout: Antigravity.config.timeout_llm, &block)
57
+ raise ProtocolError, 'Session not initialized' unless @initialized
58
+
59
+ @turn_count += 1
60
+ @last_turn_usage = empty_usage
61
+
62
+ # GHI #18: Drain any stale messages from the WebSocket buffer before sending
63
+ # a new prompt. This prevents leftover FULLY_IDLE from previous turns or init
64
+ # from being consumed by collect_response.
65
+ drain_stale_messages
66
+
67
+ # Send user input (protobuf InputEvent with user_input string field)
68
+ input_event = {
69
+ userInput: prompt
70
+ }
71
+ @ws.send_json(input_event)
72
+
73
+ # Collect response
74
+ collect_response(timeout: timeout, &block)
75
+ end
76
+
77
+ def initialized?
78
+ @initialized
79
+ end
80
+
81
+ # Generate a session summary hash (mirrors Python's metadata)
82
+ def session_summary(model: nil)
83
+ {
84
+ conversation_id: @conversation_id,
85
+ turn_count: @turn_count,
86
+ total_tokens: @total_usage[:total_token_count],
87
+ prompt_tokens: @total_usage[:prompt_token_count],
88
+ candidates_tokens: @total_usage[:candidates_token_count],
89
+ model: model
90
+ }
91
+ end
92
+
93
+ private
94
+
95
+ # GHI #18: Non-blocking drain of stale WebSocket messages (FULLY_IDLE leftovers).
96
+ # During gaps between turns (e.g. voice transcription taking 5-10s), the harness
97
+ # may send trajectory updates that would confuse the next collect_response call.
98
+ def drain_stale_messages
99
+ drained = 0
100
+ loop do
101
+ msg = @ws.receive_json(timeout: 0.05, idle_timeout: 0.05) rescue nil
102
+ break unless msg
103
+ drained += 1
104
+ @hooks&.emit(:ws_message, { _debug: 'drained_stale_message', message_keys: msg.keys, drained_count: drained })
105
+ end
106
+ @hooks&.emit(:ws_message, { _debug: 'drain_complete', count: drained }) if drained > 0
107
+ end
108
+
109
+ def collect_response(timeout: Antigravity.config.timeout_llm, &block)
110
+ text_parts = []
111
+ thinking_parts = []
112
+ steps = []
113
+ tool_calls_count = 0
114
+ finished = false
115
+ finished_at = nil
116
+ seen_any_step = false # GHI #18: track if we've seen real work from this turn
117
+ turn_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
118
+
119
+ @ws.each_message(timeout: timeout) do |msg|
120
+ @hooks&.emit(:ws_message, msg)
121
+
122
+ if (step = msg[:stepUpdate])
123
+ seen_any_step = true
124
+ step_record = parse_step(step)
125
+ steps << step_record
126
+
127
+ # Text delta — stream it (only from model aimed at user, not tool descriptions or error steps)
128
+ is_model_step = step[:source].to_s =~ /MODEL|model|3/
129
+ is_target_user = step[:target].to_s =~ /USER|user|1/
130
+ is_error_step = step[:state].to_s =~ /ERROR|error|4/ || step[:errorMessage]
131
+
132
+ if step[:textDelta] && !step[:textDelta].empty? && is_model_step && is_target_user && !is_error_step
133
+ text_parts << step[:textDelta]
134
+ chunk = Message.new(
135
+ content: step[:textDelta],
136
+ role: :assistant,
137
+ delta: true
138
+ )
139
+ block&.call(chunk)
140
+ end
141
+
142
+ # Thinking delta
143
+ if step[:thinkingDelta] && !step[:thinkingDelta].empty?
144
+ thinking_parts << step[:thinkingDelta]
145
+ end
146
+
147
+ # Custom tool action
148
+ if step[:customTool]
149
+ tool_calls_count += 1
150
+ handle_custom_tool(step)
151
+ end
152
+
153
+ # Harness built-in tool actions count
154
+ if step_record[:target] == :environment && step_record[:source] == :model
155
+ tool_calls_count += 1 unless step[:customTool]
156
+ end
157
+
158
+ # Finished? Model response to user with DONE state AND text collected.
159
+ # NOTE: DONE can arrive before text deltas, so we require text here.
160
+ # For tool-only turns (no text), FULLY_IDLE below is the authoritative stop.
161
+ if is_model_step && is_target_user && !is_error_step && step[:state] && step[:state].to_s =~ /DONE|done|2/ && !text_parts.empty?
162
+ finished = true
163
+ end
164
+ end
165
+
166
+ # Top-level tool call (custom tools are sent as separate messages, not in stepUpdate)
167
+ if (tool_call = msg[:toolCall])
168
+ tool_calls_count += 1
169
+ seen_any_step = true
170
+ handle_tool_call(tool_call)
171
+ end
172
+
173
+ # Usage update
174
+ if (usage = msg[:usageUpdate])
175
+ update_usage(usage)
176
+ seen_any_step = true
177
+ end
178
+
179
+ # Trajectory state: STATE_FULLY_IDLE / STATE_CANCELLED = turn complete (authoritative signal from harness)
180
+ # GHI #18 FIX: Only honor FULLY_IDLE if we've seen at least one stepUpdate/toolCall/usageUpdate
181
+ # from this turn, OR if enough time has elapsed (1s) that this can't be a stale leftover.
182
+ # A stale FULLY_IDLE from a previous turn sitting in the WebSocket buffer was causing
183
+ # collect_response to return immediately with zero steps/text.
184
+ if (traj = msg[:trajectoryStateUpdate])
185
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - turn_started_at
186
+ if traj[:state].to_s =~ /FULLY_IDLE|CANCELLED/
187
+ if seen_any_step || elapsed > 1.0
188
+ finished = true
189
+ finished_at = Time.now
190
+ else
191
+ # Stale FULLY_IDLE — skip it (likely leftover from previous turn or init)
192
+ @hooks&.emit(:ws_message, { _debug: 'skipped_stale_fully_idle', elapsed: elapsed.round(3) })
193
+ end
194
+ end
195
+ end
196
+
197
+ # Stop conditions (in priority order):
198
+ # 1. Session end — always stop
199
+ # 2. Model response DONE or trajectory FULLY_IDLE / CANCELLED — turn complete
200
+ if msg.key?(:sessionEndResponse) || finished
201
+ :stop
202
+ elsif !text_parts.empty?
203
+ # If assistant has sent text response, allow 3s idle timeout for trailing metadata
204
+ [:idle_timeout, 3.0]
205
+ end
206
+ end
207
+
208
+ # Build final message
209
+ Message.new(
210
+ content: text_parts.join,
211
+ role: :assistant,
212
+ thinking: thinking_parts.join,
213
+ steps: steps,
214
+ tool_calls_count: tool_calls_count,
215
+ usage: @last_turn_usage.dup
216
+ )
217
+ end
218
+
219
+ def handle_custom_tool(step)
220
+ tool_data = step[:customTool]
221
+ tool_call = tool_data[:toolCall] || tool_data
222
+ handle_tool_call(tool_call)
223
+ end
224
+
225
+ # Handle a top-level toolCall message from the harness
226
+ # Format: {id: "...", name: "tool_name", argumentsJson: "{...}"}
227
+ def handle_tool_call(tool_call)
228
+ tool_id = tool_call[:id]
229
+ tool_name = tool_call[:name]
230
+
231
+ # Parse arguments from JSON string
232
+ args_json = tool_call[:argumentsJson] || tool_call[:arguments_json]
233
+ args = if args_json.is_a?(String) && !args_json.empty?
234
+ JSON.parse(args_json, symbolize_names: true)
235
+ elsif tool_call[:arguments].is_a?(Hash)
236
+ tool_call[:arguments]
237
+ else
238
+ {}
239
+ end
240
+
241
+ # Symbolize keys for Ruby kwargs
242
+ kwargs = args.transform_keys(&:to_sym)
243
+
244
+ begin
245
+ result = @tool_runner.execute(tool_name, **kwargs)
246
+ rescue ToolNotFoundError => e
247
+ result = { error: e.message }
248
+ end
249
+
250
+ # Send tool response back (protobuf InputEvent.tool_response format)
251
+ # The harness expects responseJson to be a JSON object (Python SDK wraps in {"result": ...})
252
+ result_dict = result.is_a?(Hash) ? result : { result: result.to_s }
253
+ tool_response = {
254
+ toolResponse: {
255
+ id: tool_id,
256
+ responseJson: JSON.generate(result_dict)
257
+ }
258
+ }
259
+ @ws.send_json(tool_response)
260
+ end
261
+
262
+ def parse_step(step)
263
+ {
264
+ step_index: step[:stepIndex],
265
+ state: parse_state(step[:state]),
266
+ source: parse_source(step[:source]),
267
+ target: parse_target(step[:target]),
268
+ text_delta: step[:textDelta],
269
+ text: step[:text],
270
+ thinking_delta: step[:thinkingDelta],
271
+ error: step[:errorMessage],
272
+ cascade_id: step[:cascadeId],
273
+ trajectory_id: step[:trajectoryId]
274
+ }
275
+ end
276
+
277
+ def parse_state(val)
278
+ case val.to_s
279
+ when /ACTIVE|1/ then :active
280
+ when /DONE|2/ then :done
281
+ when /WAITING|3/ then :waiting
282
+ when /ERROR|4/ then :error
283
+ else :unknown
284
+ end
285
+ end
286
+
287
+ def parse_source(val)
288
+ case val.to_s
289
+ when /SYSTEM|1/ then :system
290
+ when /USER|2/ then :user
291
+ when /MODEL|3/ then :model
292
+ else :unknown
293
+ end
294
+ end
295
+
296
+ def parse_target(val)
297
+ case val.to_s
298
+ when /USER|1/ then :user
299
+ when /MODEL|2/ then :model
300
+ when /ENVIRONMENT|3/ then :environment
301
+ else :unknown
302
+ end
303
+ end
304
+
305
+ def update_usage(usage)
306
+ # The harness sends: { total: { promptTokenCount: "3855", ... }, agents: [...] }
307
+ # Fall back to cumulativeUsage (legacy) or flat usage hash
308
+ meta = usage[:total] || usage[:cumulativeUsage] || usage
309
+ @last_turn_usage = {
310
+ prompt_token_count: meta[:promptTokenCount].to_i,
311
+ candidates_token_count: meta[:candidatesTokenCount].to_i,
312
+ thoughts_token_count: meta[:thoughtsTokenCount].to_i,
313
+ total_token_count: meta[:totalTokenCount].to_i,
314
+ cached_content_token_count: meta[:cachedContentTokenCount].to_i
315
+ }
316
+ # Accumulate into total
317
+ @last_turn_usage.each { |k, v| @total_usage[k] += v }
318
+ end
319
+
320
+ def empty_usage
321
+ {
322
+ prompt_token_count: 0,
323
+ candidates_token_count: 0,
324
+ thoughts_token_count: 0,
325
+ total_token_count: 0,
326
+ cached_content_token_count: 0
327
+ }
328
+ end
329
+
330
+ def next_seq
331
+ @seq_counter ||= 0
332
+ @seq_counter += 1
333
+ end
334
+ end
335
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Antigravity
4
4
  EMOJIS = {
5
+ antigravity: "🛰️",
5
6
  gem: "💎",
6
7
  agent: "🕵️‍♂️",
7
8
  prompt: "💬",
@@ -13,6 +14,9 @@ module Antigravity
13
14
  sidecar: "🚗",
14
15
  logger: "🪵",
15
16
  skill: "📁",
17
+ check: "✅",
18
+ magnifying: "🔍",
19
+ workspace: "📂",
16
20
  message: "💬",
17
21
  guard: "🛡️",
18
22
  test: "🧪",