antigravity-sdk 0.3.0 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 665ab001fa484b0bc5ba97c2e7feb960712b0f8e5070ed521f0caf5ad0f16a58
4
- data.tar.gz: 3f556321b1083daadea10792632ac216ba168abb26708fc5cc3a81824301d460
3
+ metadata.gz: b2a5cad1edda5eb5efaa58d7d5104ba433f8469a923bb5f862b9f1bcb4219021
4
+ data.tar.gz: 75c9924910ef8aab407444abfecfa0f7e989473d8be251691e4e07c9d04499aa
5
5
  SHA512:
6
- metadata.gz: d60ff58a66fec4ed17a85ad51015568e743363f313eb1a27b9e6512a767ae5a7abca062796ae0bc19edbe59545202828415b693f6551b77a22d8a1ff5cca99ea
7
- data.tar.gz: 9e09ed8b23e59d29f644d10f421b73d70f357c7d9fd601ba67ba429df8c2814768d0264084e5e6439cd9161d2f8c13313f6890bcd11faf6f66f7e43e3ea2578f
6
+ metadata.gz: ac637d9792051869f94b4a8ebc716fd604b2db18886e19b105190a9636c7e7bce418aeece52a360b139b1c534e560605b0dc578fab10b3e06ac913dc454663eb
7
+ data.tar.gz: c8b6e4bee541853abd1f993d5423cc3426c830a24aba58f6790ec30e093279163b83e258a824811380ac5a603cba749dd7a099dee5ad4a2160fe25a7373c6e51
@@ -4,27 +4,122 @@ module Antigravity
4
4
  class Agent < Base
5
5
 
6
6
  attr_accessor :model, :system_instruction, :api_key
7
- attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard
7
+ attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard,
8
+ :workspace, :connection, :conversation
8
9
 
9
- def initialize(model: nil, auto_logger: true, &block)
10
+ def initialize(model: nil, system_instruction: nil, tools: [],
11
+ skills: [], workspace: nil, auto_logger: true, log_file: nil, &block)
10
12
  @model = model || Antigravity.config.default_model
11
13
  @api_key = Antigravity.config.api_key
12
- @system_instruction = nil
13
- @tools = []
14
+ @system_instruction = system_instruction
15
+ @workspace = workspace ? File.expand_path(workspace) : nil
16
+ @tools = tools.dup
14
17
  @skills = []
15
18
  @sidecars = []
16
19
  @hooks = Hooks.new
17
20
  @client = Client.new
18
21
  @logger_guard = nil
22
+ @connection = nil
23
+ @conversation = nil
24
+ @connected = false
25
+
26
+ # Register pre-provided tools into the tool runner
27
+ @tool_runner = ToolRunner.new
28
+ @tools.each { |t| @tool_runner.register(t) }
29
+
30
+ # Load skills provided at construction (local paths or GitHub URLs)
31
+ add_skills(skills) unless Array(skills).empty?
19
32
 
20
33
  # Automagic Logger attachment unless disabled via ENV["ANTIGRAVITY_LOGGER"]=false or auto_logger: false
21
34
  if auto_logger && logger_enabled?
22
- attach_logger
35
+ attach_logger(log_file)
23
36
  end
24
37
 
25
38
  yield(self) if block_given?
26
39
  end
27
40
 
41
+ # --- Class Methods ---
42
+
43
+ # Block form: opens connection, yields agent, auto-closes.
44
+ def self.open(**kwargs, &block)
45
+ agent = new(**kwargs)
46
+ agent.connect!
47
+ begin
48
+ block.call(agent)
49
+ ensure
50
+ agent.close!
51
+ end
52
+ end
53
+
54
+ # --- Connection Lifecycle ---
55
+
56
+ def connect!
57
+ return self if @connected
58
+
59
+ @connection = Connection::LocalConnection.new
60
+ @connection.connect!
61
+
62
+ @conversation = Conversation.new(
63
+ ws_client: @connection.ws_client,
64
+ tool_runner: @tool_runner,
65
+ hooks: @hooks
66
+ )
67
+
68
+ harness_config = build_harness_config
69
+ @conversation.initialize_session!(harness_config: harness_config)
70
+ @connected = true
71
+ self
72
+ end
73
+
74
+ def connected?
75
+ @connected && @connection&.connected?
76
+ end
77
+
78
+ def close!
79
+ @connected = false
80
+ @connection&.disconnect!
81
+ @connection = nil
82
+ @conversation = nil
83
+ end
84
+
85
+ # --- Chat ---
86
+
87
+ def prompt(message, timeout: Antigravity.config.timeout_llm, &block)
88
+ emit_sidecar_event(:prompt_started, prompt: message)
89
+ hooks.run_pre_prompt(message)
90
+
91
+ if @connected && @conversation
92
+ response = @conversation.chat(message, timeout: timeout, &block)
93
+ else
94
+ # Legacy mock-client path (unit tests, pre-connection)
95
+ response = client.send_turn(self, message, &block)
96
+ end
97
+
98
+ hooks.run_post_response(response)
99
+ emit_sidecar_event(:turn_completed, response: response.content, model: model)
100
+
101
+ response
102
+ end
103
+ alias ask prompt
104
+
105
+ # --- Metadata Accessors (mirrors Python SDK) ---
106
+
107
+ def conversation_id
108
+ @conversation&.conversation_id
109
+ end
110
+
111
+ def turn_count
112
+ @conversation&.turn_count || 0
113
+ end
114
+
115
+ def session_summary
116
+ return {} unless @conversation
117
+
118
+ @conversation.session_summary(model: @model)
119
+ end
120
+
121
+ # --- Tool Registration ---
122
+
28
123
  def register_tool(tool_or_name = nil, description: "", &block)
29
124
  if block_given? && tool_or_name
30
125
  tool = Tool::Dynamic.new(tool_or_name, description: description, &block)
@@ -34,6 +129,7 @@ module Antigravity
34
129
  raise ArgumentError, "Invalid tool definition"
35
130
  end
36
131
  @tools << tool
132
+ @tool_runner.register(tool) if @tool_runner
37
133
  tool
38
134
  end
39
135
 
@@ -42,18 +138,61 @@ module Antigravity
42
138
  sidecar
43
139
  end
44
140
 
45
- def attach_logger(log_target = nil, level: ::Logger::INFO, silent_notice: false)
141
+ def attach_logger(log_target = nil, level: :info, silent_notice: false)
46
142
  @logger_guard = Guards::AgentLogger.new(log_target, level: level, silent_notice: silent_notice)
47
143
  @logger_guard.attach_to(self)
48
144
  @logger_guard
49
145
  end
50
146
 
51
- def load_skill(skill_path)
52
- skill = Skill.load(skill_path)
53
- @skills << skill
147
+ # Add a single skill by path or GitHub URL.
148
+ # Raises if the path resolves to multiple skills (use add_skills instead).
149
+ # @param path_or_url [String] local path or GitHub URL
150
+ # @param skill_name [String, nil] optional specific skill name within a repo
151
+ # @return [Skill] the loaded skill
152
+ def add_skill(path_or_url, skill_name: nil)
153
+ target = skill_name ? "#{path_or_url.to_s.chomp('/')}/#{skill_name}" : path_or_url.to_s
154
+ paths = SkillResolver.resolve(target)
155
+ if paths.size > 1
156
+ raise ArgumentError,
157
+ "add_skill resolved to #{paths.size} skills. Use add_skills instead, " \
158
+ "or specify skill_name: to pick one."
159
+ end
160
+ raise ArgumentError, "No skill found at #{target}" if paths.empty?
161
+
162
+ load_single_skill(paths.first)
163
+ end
164
+
165
+ # Add one or more skills by path or GitHub URL.
166
+ # Accepts a single string or an array. Each entry is resolved (may expand to multiple).
167
+ # @param paths_or_urls [String, Array<String>] local paths or GitHub URLs
168
+ # @return [Array<Skill>] all loaded skills
169
+ def add_skills(paths_or_urls)
170
+ Array(paths_or_urls).flat_map do |p|
171
+ SkillResolver.resolve(p).map { |skill_path| load_single_skill(skill_path) }
172
+ end
173
+ end
174
+
175
+ # Create and add an inline skill (no file needed).
176
+ # @param name [String] skill name
177
+ # @param description [String] what the skill does
178
+ # @param instructions [String] the skill body (markdown)
179
+ # @return [Skill] the inline skill
180
+ def add_inline_skill(name:, description:, instructions:)
181
+ skill = Skill.inline(name: name, description: description, instructions: instructions)
182
+ @skills << skill unless @skills.any? { |s| s.name == skill.name }
54
183
  skill
55
184
  end
56
185
 
186
+ # List discovered skills in a container path (without loading them).
187
+ # @param path_or_url [String] local path or GitHub URL
188
+ # @return [Array<String>] skill directory paths
189
+ def self.list_skills(path_or_url)
190
+ SkillResolver.resolve(path_or_url)
191
+ end
192
+
193
+ # Legacy alias
194
+ alias_method :load_skill, :add_skill
195
+
57
196
  def before_prompt(&block)
58
197
  hooks.before_prompt(&block)
59
198
  end
@@ -75,19 +214,6 @@ module Antigravity
75
214
  @sidecars.each { |sidecar| sidecar.emit(event_type, payload) }
76
215
  end
77
216
 
78
- def prompt(message, &block)
79
- emit_sidecar_event(:prompt_started, prompt: message)
80
- hooks.run_pre_prompt(message)
81
-
82
- response = client.send_turn(self, message, &block)
83
-
84
- hooks.run_post_response(response)
85
- emit_sidecar_event(:turn_completed, response: response.content, model: model)
86
-
87
- response
88
- end
89
- alias ask prompt
90
-
91
217
  private
92
218
 
93
219
  def logger_enabled?
@@ -96,5 +222,91 @@ module Antigravity
96
222
 
97
223
  true
98
224
  end
225
+
226
+ def build_harness_config
227
+ api_key = ENV.fetch('GEMINI_API_KEY') {
228
+ raise ConfigError, 'GEMINI_API_KEY environment variable is required'
229
+ }
230
+
231
+ config = {
232
+ config: {
233
+ models: [
234
+ {
235
+ name: @model,
236
+ geminiApiEndpoint: {
237
+ apiKey: api_key
238
+ }
239
+ }
240
+ ]
241
+ }
242
+ }
243
+
244
+ # Add workspaces if specified
245
+ if @workspace
246
+ expanded = File.expand_path(@workspace)
247
+ $stderr.puts "\u231B Indexing workspace: #{expanded} — this may take a moment..."
248
+ config[:config][:workspaces] = [
249
+ {
250
+ filesystemWorkspace: {
251
+ directory: expanded
252
+ }
253
+ }
254
+ ]
255
+ end
256
+
257
+ # Add system instructions if specified (protobuf: SystemInstructions.custom.part[])
258
+ effective_instructions = @system_instruction || ''
259
+
260
+ # Auto-append workspace tool hints — models (esp. flash-lite) won't use tools unless told
261
+ if @workspace && !effective_instructions.match?(/list_dir|view_file|tools/i)
262
+ tool_hint = 'You have access to the workspace filesystem. Use the available tools (list_dir, view_file, grep_search) to explore it.'
263
+ effective_instructions = effective_instructions.empty? ? tool_hint : "#{effective_instructions}\n#{tool_hint}"
264
+ end
265
+
266
+ unless effective_instructions.empty?
267
+ config[:config][:systemInstructions] = {
268
+ custom: {
269
+ part: [{ text: effective_instructions }]
270
+ }
271
+ }
272
+ end
273
+
274
+ # Add custom tools
275
+ if @tool_runner && !@tool_runner.empty?
276
+ config[:config][:tools] = @tool_runner.to_harness_tools
277
+ end
278
+
279
+ # Enable harness-side built-in tools by default (list_dir, view_file, grep_search, etc.)
280
+ config[:config][:harnessSideTools] = {
281
+ listDir: { enabled: true },
282
+ viewFile: { enabled: true },
283
+ grepSearch: { enabled: true },
284
+ find: { enabled: true },
285
+ writeToFile: { enabled: true },
286
+ fileEdit: { enabled: true },
287
+ readUrlContent: { enabled: true },
288
+ searchWeb: { enabled: true }
289
+ }
290
+
291
+ # Wire skills paths for harness (proto field: skills_paths)
292
+ unless @skills.empty?
293
+ skill_paths = @skills.select(&:path).map(&:path)
294
+ config[:config][:skillsPaths] = skill_paths unless skill_paths.empty?
295
+ end
296
+
297
+ # App data dir
298
+ config[:config][:appDataDir] = File.expand_path('~/.gemini/antigravity')
299
+
300
+ config
301
+ end
302
+
303
+ def load_single_skill(skill_path)
304
+ # Dedup by path
305
+ return @skills.find { |s| s.path == skill_path } if @skills.any? { |s| s.path == skill_path }
306
+
307
+ skill = Skill.load(skill_path)
308
+ @skills << skill
309
+ skill
310
+ end
99
311
  end
100
312
  end
@@ -4,11 +4,22 @@ module Antigravity
4
4
  class Config
5
5
  attr_accessor :api_key, :default_model, :harness_path, :log_level
6
6
 
7
+ # Timeouts (seconds) — aggressive for dev, override via ENV for prod.
8
+ # ANTIGRAVITY_TIMEOUT_LLM — per-message wait for LLM response (default: 20s)
9
+ # ANTIGRAVITY_TIMEOUT_WS — WebSocket connect/handshake (default: 3s)
10
+ # ANTIGRAVITY_TIMEOUT_HANDSHAKE — stdio binary handshake (default: 5s)
11
+ attr_accessor :timeout_llm, :timeout_ws, :timeout_handshake
12
+
7
13
  def initialize
8
14
  @api_key = ENV["GEMINI_API_KEY"]
9
- @default_model = "gemini-flash-latest"
15
+ @default_model = ENV["GEMINI_MODEL"] || ENV["ANTIGRAVITY_MODEL"] || "gemini-3.6-flash"
10
16
  @harness_path = ENV["ANTIGRAVITY_HARNESS_PATH"] || File.expand_path("~/.antigravity/bin/localharness")
11
17
  @log_level = :info
18
+
19
+ # Timeouts: aggressive for dev, relax via ENV for production
20
+ @timeout_llm = (ENV["ANTIGRAVITY_TIMEOUT_LLM"] || 40).to_i
21
+ @timeout_ws = (ENV["ANTIGRAVITY_TIMEOUT_WS"] || 3).to_i
22
+ @timeout_handshake = (ENV["ANTIGRAVITY_TIMEOUT_HANDSHAKE"] || 5).to_i
12
23
  end
13
24
 
14
25
  def api_key?
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+ require 'net/http'
7
+ require 'json'
8
+ require 'uri'
9
+
10
+ module Antigravity
11
+ module Connection
12
+ # Downloads and extracts the localharness binary from the official
13
+ # google-antigravity PyPI wheel. Used when the binary isn't already
14
+ # installed via Antigravity.app or a manual setup.
15
+ module BinaryFetcher
16
+ PYPI_PACKAGE = 'google-antigravity'
17
+ INSTALL_DIR = File.expand_path('~/.antigravity/bin')
18
+ BINARY_NAME = 'localharness'
19
+
20
+ # Platform mapping: Ruby's RUBY_PLATFORM -> PyPI wheel platform tag
21
+ PLATFORM_MAP = {
22
+ /darwin.*arm/i => 'macosx_11_0_arm64',
23
+ /darwin.*x86/i => 'macosx_10_15_x86_64',
24
+ /darwin/i => 'macosx_11_0_arm64', # default macOS = ARM
25
+ /linux.*x86_64/i => 'manylinux2014_x86_64',
26
+ /linux.*aarch/i => 'manylinux2014_aarch64',
27
+ }.freeze
28
+
29
+ class << self
30
+ # Fetch and install the binary. Returns the installed path.
31
+ # @param quiet [Boolean] suppress progress output
32
+ # @return [String] path to the installed binary
33
+ def fetch!(quiet: false)
34
+ platform = detect_platform
35
+ say("🔍 Detecting platform: #{platform}", quiet: quiet)
36
+
37
+ # Step 1: Find the wheel URL from PyPI
38
+ say("📡 Querying PyPI for #{PYPI_PACKAGE}...", quiet: quiet)
39
+ wheel_url = find_wheel_url(platform)
40
+
41
+ # Step 2: Download the wheel
42
+ say("⏳ Downloading binary... this will take some time (~50MB wheel)", quiet: quiet)
43
+ wheel_path = download_wheel(wheel_url, quiet: quiet)
44
+
45
+ # Step 3: Extract the binary
46
+ say("📦 Extracting localharness binary...", quiet: quiet)
47
+ binary_path = extract_binary(wheel_path)
48
+
49
+ # Step 4: Make executable
50
+ FileUtils.chmod(0o755, binary_path)
51
+ say("✅ Installed localharness at #{binary_path}", quiet: quiet)
52
+
53
+ binary_path
54
+ ensure
55
+ # Clean up temp wheel
56
+ FileUtils.rm_f(wheel_path) if wheel_path && File.exist?(wheel_path.to_s)
57
+ end
58
+
59
+ # Check if the binary is already installed via fetch
60
+ def installed?
61
+ path = File.join(INSTALL_DIR, BINARY_NAME)
62
+ File.executable?(path)
63
+ end
64
+
65
+ def installed_path
66
+ File.join(INSTALL_DIR, BINARY_NAME)
67
+ end
68
+
69
+ private
70
+
71
+ def detect_platform
72
+ PLATFORM_MAP.each do |pattern, tag|
73
+ return tag if RUBY_PLATFORM.match?(pattern)
74
+ end
75
+ raise HarnessNotFoundError,
76
+ "Unsupported platform: #{RUBY_PLATFORM}. Cannot auto-download localharness."
77
+ end
78
+
79
+ def find_wheel_url(platform)
80
+ uri = URI("https://pypi.org/pypi/#{PYPI_PACKAGE}/json")
81
+ response = Net::HTTP.get(uri)
82
+ data = JSON.parse(response)
83
+
84
+ # Find latest version's wheel for our platform
85
+ urls = data['urls'] || []
86
+ wheel = urls.find { |u| u['filename']&.include?(platform) && u['filename']&.end_with?('.whl') }
87
+
88
+ unless wheel
89
+ # Try all versions for the platform
90
+ versions = data['releases']&.keys&.sort_by { |v| Gem::Version.new(v) rescue v }&.reverse
91
+ versions&.each do |ver|
92
+ files = data.dig('releases', ver) || []
93
+ wheel = files.find { |u| u['filename']&.include?(platform) && u['filename']&.end_with?('.whl') }
94
+ break if wheel
95
+ end
96
+ end
97
+
98
+ raise HarnessNotFoundError,
99
+ "No wheel found for platform #{platform} on PyPI. Install Antigravity.app manually." unless wheel
100
+
101
+ wheel['url']
102
+ end
103
+
104
+ def download_wheel(url, quiet: false)
105
+ dest = File.join(Dir.tmpdir, "antigravity-wheel-#{$$}.whl")
106
+ uri = URI(url)
107
+
108
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
109
+ request = Net::HTTP::Get.new(uri)
110
+ http.request(request) do |response|
111
+ total = response['content-length']&.to_i
112
+ downloaded = 0
113
+
114
+ File.open(dest, 'wb') do |f|
115
+ response.read_body do |chunk|
116
+ f.write(chunk)
117
+ downloaded += chunk.bytesize
118
+ if total && total > 0 && !quiet
119
+ pct = (downloaded * 100.0 / total).round(1)
120
+ print "\r ⏳ #{pct}% (#{(downloaded / 1024.0 / 1024).round(1)} MB / #{(total / 1024.0 / 1024).round(1)} MB)"
121
+ end
122
+ end
123
+ end
124
+ puts unless quiet
125
+ end
126
+ end
127
+
128
+ dest
129
+ end
130
+
131
+ def extract_binary(wheel_path)
132
+ FileUtils.mkdir_p(INSTALL_DIR)
133
+
134
+ # Wheels are just ZIP files. Extract the binary.
135
+ # Look for: google/antigravity/bin/localharness (or language_server)
136
+ extract_dir = Dir.mktmpdir('agy-extract-')
137
+ system('unzip', '-q', '-o', wheel_path, '-d', extract_dir)
138
+
139
+ # Search for the binary inside (prefer localharness over language_server)
140
+ candidates = Dir.glob("#{extract_dir}/**/localharness") +
141
+ Dir.glob("#{extract_dir}/**/language_server")
142
+
143
+ binary = candidates.find { |f| File.file?(f) && !File.directory?(f) }
144
+
145
+ unless binary
146
+ FileUtils.rm_rf(extract_dir)
147
+ raise HarnessNotFoundError,
148
+ "Could not find localharness binary inside the wheel. Contents: #{Dir.glob("#{extract_dir}/**/*").join(', ')}"
149
+ end
150
+
151
+ dest_path = File.join(INSTALL_DIR, BINARY_NAME)
152
+ FileUtils.cp(binary, dest_path)
153
+ FileUtils.rm_rf(extract_dir)
154
+ dest_path
155
+ end
156
+
157
+ def say(msg, quiet: false)
158
+ $stderr.puts msg unless quiet
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -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