ace-llm-providers-cli 0.31.7 → 0.33.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6be56a7438a2d8daa7a1081c83ab44733245a52f1028fc1016d920548b69255e
4
- data.tar.gz: be8ba7f7231136ef005f35b672fd546fa6d29ea9495784a6b41e41848754b29d
3
+ metadata.gz: 7410921d087bbcfa634cec60984b2d578de92d92d84fc0730aa4cae054848b4b
4
+ data.tar.gz: '08b71084f7f13ac67941da17c20e84b34c1dd52673f037a929ac7e896e0c2310'
5
5
  SHA512:
6
- metadata.gz: 5f624dcdabb360ea555dac5ca0b73a9721cd088ae46e9000a6ae8214000450638be83c258283dd574d4846a27fc9f57ae557e85ec8a0d5ac6a64cbb781402c68
7
- data.tar.gz: 535009d237c56c4d93e3b0740376879d71f449f4d5fe22539890f704c6c39c78fe8c6b78687b69fa16b31cf24659813daeace393c52af14b54840479ed711adf
6
+ metadata.gz: 92613d00db61f1ad8e8a1e99285c478526b38dd998bd26355a543939ba2453da13f23df97479ae9a4186653fce4b31bd4383dc8fa23ff69c9a887ede90d8c339
7
+ data.tar.gz: 50d6485baf304a089b0311a3f0fe096fc90bb697a1b72b4c43f27d295a21631753e1e1d8be718c89722a71e51d3cbfa296926d49fe46875844d4dd53b4969e0a
@@ -0,0 +1,29 @@
1
+ name: agy
2
+ last_synced: 2026-08-29
3
+ class: Ace::LLM::Providers::CLI::AgyClient
4
+ gem: ace-llm-providers-cli
5
+ models_dev_id: google
6
+ context_limit: 1000000
7
+ models:
8
+ - gemini-3.7-flash-high
9
+ - gemini-3.7-flash-medium
10
+ - gemini-3.6-flash-high
11
+ - gemini-3.6-flash-medium
12
+ - gemini-3.5-flash-medium
13
+ - gemini-3.1-pro-high
14
+ - claude-sonnet-4-6
15
+ aliases:
16
+ global:
17
+ agy: agy:gemini-3.5-flash-medium
18
+ model:
19
+ flash: gemini-3.5-flash-medium
20
+ flash-medium: gemini-3.5-flash-medium
21
+ flash-high: gemini-3.7-flash-high
22
+ pro: gemini-3.1-pro-high
23
+ capabilities:
24
+ - text_generation
25
+ - code_generation
26
+ - streaming
27
+ - function_calling
28
+ default_options:
29
+ temperature: 0.7
@@ -3,11 +3,9 @@ class: Ace::LLM::Providers::CLI::PiClient
3
3
  gem: ace-llm-providers-cli
4
4
  models:
5
5
  # ZAI (default provider)
6
- - zai/glm-4.7
7
- - zai/glm-4.6
8
- - zai/glm-4.5
9
- - zai/glm-5.1
10
- - zai/glm-5-turbo
6
+ - zai/glm-5.3
7
+ - zai/glm-5.3-flash
8
+ - zai/glm-5.3-highspeed
11
9
  # Anthropic
12
10
  - anthropic/claude-opus-4-6
13
11
  - anthropic/claude-opus-4-5
@@ -26,10 +24,11 @@ models:
26
24
  - openai-codex/gpt-5.1
27
25
  aliases:
28
26
  global:
29
- pi: pi:zai/glm-5.1
27
+ pi: pi:zai/glm-5.3-flash
30
28
  model:
31
- glm: zai/glm-5.1
32
- glmturbo: zai/glm-5-turbo
29
+ glm: zai/glm-5.3
30
+ glmflash: zai/glm-5.3-flash
31
+ glmhighspeed: zai/glm-5.3-highspeed
33
32
  gpt: openai-codex/gpt-5.4
34
33
  capabilities:
35
34
  - text_generation
data/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+ - Hardened `SafeCapture` process group termination for container PID namespaces where background processes reparent to PID 1, ensuring reliable cleanup of descendant processes without ESRCH failures.
12
+
13
+ ## [0.33.0] - 2026-09-04
14
+
15
+ ### Changed
16
+ - Registered the live Pi/Z.AI GLM 5.3 family (`zai/glm-5.3`, `zai/glm-5.3-flash`, `zai/glm-5.3-highspeed`) in the shipped provider registry and removed the obsolete GLM 4.x/5.1/5-turbo entries and `glmturbo` alias.
17
+ - Made `zai/glm-5.3-flash` the routine Pi default: the global `pi` alias and `PiClient` default model now target the flash model, with `glm`, `glmflash`, and `glmhighspeed` model aliases for the three live targets.
18
+
19
+ ### Technical
20
+ - Added focused regression coverage for registry parity between project and shipped defaults, alias and full-slash-selector resolution without truncation, unknown-model fail-closed behavior, and `--no-fallback` preserving exactly one provider/model target.
21
+
22
+ ## [0.32.4] - 2026-09-02
23
+
24
+ ### Technical
25
+ - Included in the coordinated all-package patch release preparation for ACE monorepo review.
26
+ ## [0.32.3] - 2026-08-29
27
+
28
+ ### Fixed
29
+ - Split `agy` readiness from headless-auth verification so installed, callable Antigravity CLI setups report as ready without being misclassified as blocked on authentication.
30
+
31
+ ### Technical
32
+ - Added focused readiness regression coverage and updated the retained CLI smoke scenario to exercise the five-provider surface, including the `agy` ready-but-unverified path.
33
+
34
+ ## [0.32.2] - 2026-08-29
35
+
36
+ ### Fixed
37
+ - Switched the Antigravity CLI one-shot prompt guard to enforce the configured limit by byte size, preventing multibyte prompts from bypassing the argv launch safety check.
38
+ - Corrected the `agy` readiness check to report installed-but-unverified headless authentication instead of claiming the provider is already authenticated.
39
+
40
+ ### Technical
41
+ - Added regression coverage for multibyte prompt-limit enforcement and the updated `agy` readiness semantics.
42
+
43
+ ## [0.32.1] - 2026-08-29
44
+
45
+ ### Fixed
46
+ - Rejected oversized Antigravity CLI one-shot prompts with a clear provider error before they can fail at process launch on OS argv-length limits.
47
+ - Treated installed `agy` binaries as configured in readiness checks instead of reporting a hard-coded unauthenticated false negative.
48
+
49
+ ### Technical
50
+ - Added regression coverage for the Antigravity oversized-prompt guard and configured-readiness behavior.
51
+
52
+ ## [0.32.0] - 2026-08-29
53
+
54
+ ### Added
55
+ - Added first-class Antigravity CLI (`agy`) provider support with documented model aliases, one-shot JSON output handling, conversation resume metadata, timeout forwarding, and health-check coverage.
56
+
57
+ ### Technical
58
+ - Added retained provider smoke coverage and deterministic registration/command-construction tests for the new Antigravity adapter.
59
+
10
60
  ## [0.31.7] - 2026-08-12
11
61
 
12
62
  ### Technical
data/README.md CHANGED
@@ -12,11 +12,11 @@
12
12
 
13
13
  </div>
14
14
 
15
- > Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
15
+ > Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, Antigravity CLI, pi-agent, and more.
16
16
 
17
17
  [ace-llm Usage Guide](../ace-llm/docs/usage.md) | [ace-llm Handbook](../ace-llm/docs/handbook.md) | Part of [ACE](https://github.com/cs3b/ace)
18
18
 
19
- `ace-llm-providers-cli` extends [ace-llm](../ace-llm) with provider clients that execute through installed CLI tools (Claude, Codex, OpenCode, Gemini, pi, Codex OSS) while preserving the shared command interface. Provider defaults live in versioned YAML, and a health-check command verifies local readiness.
19
+ `ace-llm-providers-cli` extends [ace-llm](../ace-llm) with provider clients that execute through installed CLI tools (Antigravity, Claude, Codex, OpenCode, Gemini, pi, Codex OSS) while preserving the shared command interface. Provider defaults live in versioned YAML, and a health-check command verifies local readiness.
20
20
 
21
21
  This package intentionally does not ship a package-local `docs/` directory. Public usage and operator guidance live in the shared [ace-llm Usage Guide](../ace-llm/docs/usage.md), while `ace-llm-providers-cli-check --help` remains the authoritative runtime help surface for local readiness checks.
22
22
 
@@ -28,20 +28,21 @@ This package intentionally does not ship a package-local `docs/` directory. Publ
28
28
 
29
29
  ## Use Cases
30
30
 
31
- **Use CLI-native providers through one surface** - run prompts against Claude, Codex, OpenCode, Gemini, pi, and Codex OSS via [ace-llm](../ace-llm) without changing calling conventions.
31
+ **Use CLI-native providers through one surface** - run prompts against Antigravity, Claude, Codex, OpenCode, Gemini, pi, and Codex OSS via [ace-llm](../ace-llm) without changing calling conventions.
32
32
 
33
33
  **Keep provider configuration in versioned YAML** - tune model behavior and provider settings through [ace-support-config](../ace-support-config) instead of custom glue code.
34
34
 
35
35
  **Diagnose local provider readiness** - run `ace-llm-providers-cli-check` to verify that CLI tools are installed, authenticated, and reachable before starting work.
36
36
 
37
+ **Antigravity contract note** - the `agy` adapter follows Antigravity's documented headless contract as verified on August 29, 2026, including `-p`, `--model`, JSON output, and conversation resume flags forwarded through `cli_args`.
38
+
37
39
  ## Testing
38
40
 
39
41
  - `ace-test ace-llm-providers-cli` runs deterministic fast coverage from `test/fast/`.
40
42
  - `ace-test ace-llm-providers-cli feat` runs deterministic feature coverage from `test/feat/`.
41
43
  - `ace-test ace-llm-providers-cli all` runs fast + feat deterministic coverage.
42
44
  - `ace-test-e2e ace-llm-providers-cli` runs retained workflow scenarios from `test/e2e/`.
43
- - E2E provider-discovery scenarios avoid helper interception: the no-tools path uses a baseline PATH
44
- (`/usr/bin:/bin`), and success-path stubs are kept as text scripts in a dedicated sandbox stub directory.
45
+ - E2E provider-discovery scenarios avoid helper interception: the no-tools path uses a baseline PATH (`/usr/bin:/bin`), and success-path stubs are kept as text scripts in a dedicated sandbox stub directory.
45
46
 
46
47
  The package no longer uses legacy `test/integration` naming and does not use
47
48
  `ace-test ace-llm-providers-cli e2e` as a deterministic test path.
@@ -12,6 +12,7 @@ if ARGV.include?("--help") || ARGV.include?("-h") || ARGV.include?("help")
12
12
  -h, --help Display this help message
13
13
 
14
14
  This utility checks for the following CLI tools:
15
+ - Antigravity (agy)
15
16
  - Claude Code (claude)
16
17
  - Codex (codex)
17
18
  - OpenCode (opencode)
@@ -36,7 +37,7 @@ max_name_length = results.map { |r| r[:name].length }.max
36
37
  results.each do |result|
37
38
  name_padded = result[:name].ljust(max_name_length)
38
39
  status_icon = result[:available] ? "✅" : "❌"
39
- auth_icon = result[:authenticated] ? "🔓" : "🔒"
40
+ auth_icon = result[:ready] ? "🔓" : "🔒"
40
41
  config = result[:config]
41
42
 
42
43
  puts "#{status_icon} #{name_padded} (#{result[:provider]})"
@@ -56,16 +57,18 @@ end
56
57
  # Summary
57
58
  available_count = results.count { |r| r[:available] }
58
59
  authenticated_count = results.count { |r| r[:authenticated] }
60
+ ready_count = results.count { |r| r[:ready] }
59
61
 
60
62
  puts "─" * 50
61
63
  puts "\n📊 Summary:"
62
64
  puts " Available: #{available_count}/#{results.size} CLI tools installed"
63
65
  puts " Authenticated: #{authenticated_count}/#{available_count} tools authenticated" if available_count > 0
66
+ puts " Ready: #{ready_count}/#{available_count} tools ready for use" if available_count > 0
64
67
 
65
68
  if available_count == 0
66
69
  puts "\n💡 To use CLI providers, install at least one tool from above."
67
70
  puts " After installation, run this check again to verify authentication."
68
- elsif authenticated_count < available_count
71
+ elsif ready_count < available_count
69
72
  puts "\n💡 Some tools need authentication. Run the auth commands shown above."
70
73
  else
71
74
  puts "\n🎉 All installed CLI tools are ready to use with ace-llm!"
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "cli_args_support"
6
+ require_relative "atoms/execution_context"
7
+
8
+ module Ace
9
+ module LLM
10
+ module Providers
11
+ module CLI
12
+ # Client for interacting with Antigravity CLI
13
+ class AgyClient < Ace::LLM::Organisms::BaseClient
14
+ include CliArgsSupport
15
+
16
+ API_BASE_URL = "https://antigravity.google"
17
+ DEFAULT_GENERATION_CONFIG = {}.freeze
18
+ DEFAULT_MODEL = "gemini-3.5-flash-medium"
19
+ DEFAULT_MAX_PROMPT_LENGTH = 100_000
20
+
21
+ def initialize(model: nil, **options)
22
+ @model = model || DEFAULT_MODEL
23
+ @options = options
24
+ @generation_config = options[:generation_config] || {}
25
+ end
26
+
27
+ def self.provider_name
28
+ "agy"
29
+ end
30
+
31
+ def needs_credentials?
32
+ false
33
+ end
34
+
35
+ def list_models
36
+ [
37
+ {id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", context_size: 1_000_000},
38
+ {id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", context_size: 1_000_000},
39
+ {id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", context_size: 1_000_000},
40
+ {id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", context_size: 1_000_000},
41
+ {id: "gemini-3.5-flash-medium", name: "Gemini 3.5 Flash (Medium)", context_size: 1_000_000},
42
+ {id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)", context_size: 1_000_000},
43
+ {id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)", context_size: 200_000}
44
+ ]
45
+ end
46
+
47
+ def generate(messages, **options)
48
+ validate_agy_availability!
49
+ validate_supported_options!(options)
50
+
51
+ prompt = format_messages_as_prompt(prepare_messages(messages, options))
52
+ prompt = build_full_prompt(prompt, options)
53
+ subprocess_env = options[:subprocess_env]
54
+ working_dir = Atoms::ExecutionContext.resolve_working_dir(
55
+ working_dir: options[:working_dir],
56
+ subprocess_env: subprocess_env
57
+ )
58
+
59
+ cmd = build_agy_command(prompt, options)
60
+ stdout, stderr, status = execute_agy_command(cmd, working_dir: working_dir, options: options)
61
+
62
+ parse_agy_response(stdout, stderr, status, prompt)
63
+ rescue => e
64
+ handle_agy_error(e)
65
+ end
66
+
67
+ private
68
+
69
+ def prepare_messages(messages, options)
70
+ return messages if messages.is_a?(String)
71
+
72
+ process_messages_with_system_append(messages, options[:system_append])
73
+ end
74
+
75
+ def format_messages_as_prompt(messages)
76
+ return messages if messages.is_a?(String)
77
+
78
+ messages.map do |msg|
79
+ role = msg[:role] || msg["role"]
80
+ content = msg[:content] || msg["content"]
81
+
82
+ case role
83
+ when "system"
84
+ "System: #{content}"
85
+ when "user"
86
+ "User: #{content}"
87
+ when "assistant"
88
+ "Assistant: #{content}"
89
+ else
90
+ content
91
+ end
92
+ end.join("\n\n")
93
+ end
94
+
95
+ def build_full_prompt(prompt, options)
96
+ prompt_str = prompt.to_s
97
+ return prompt_str if prompt_str.start_with?("System:")
98
+
99
+ system_content = options[:system_instruction] ||
100
+ options[:system] ||
101
+ options[:system_prompt] ||
102
+ @generation_config[:system_prompt]
103
+
104
+ return prompt_str unless system_content
105
+
106
+ "System: #{system_content}\n\n#{prompt_str}"
107
+ end
108
+
109
+ def agy_available?
110
+ system("which", "agy", out: File::NULL, err: File::NULL)
111
+ end
112
+
113
+ def validate_agy_availability!
114
+ return if agy_available?
115
+
116
+ raise Ace::LLM::ProviderError,
117
+ "Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash"
118
+ end
119
+
120
+ def validate_supported_options!(options)
121
+ unsupported = %i[temperature max_tokens top_p top_k]
122
+ .select { |key| options[key] }
123
+ return if unsupported.empty?
124
+
125
+ raise Ace::LLM::ProviderError,
126
+ "Antigravity CLI does not support #{unsupported.join(", ")} through ace-llm; use documented cli_args instead"
127
+ end
128
+
129
+ def build_agy_command(prompt, options)
130
+ args = normalized_cli_args(options)
131
+ reject_stream_input_args!(args)
132
+ reject_oversized_prompt!(prompt)
133
+
134
+ cmd = ["agy", "-p", prompt.to_s, "--output-format", "json"]
135
+ cmd << "--model" << @model if @model
136
+ cmd << "--print-timeout" << normalize_print_timeout(options[:timeout]) if options[:timeout]
137
+ cmd << "--sandbox" if options[:sandbox]
138
+ cmd.concat(args)
139
+ cmd
140
+ end
141
+
142
+ def reject_stream_input_args!(args)
143
+ flag = find_conflicting_cli_arg(args, ["--input-format"])
144
+ return unless flag
145
+
146
+ raise Ace::LLM::ProviderError,
147
+ "Antigravity one-shot mode does not support cli arg #{flag}; stream-json input requires stdin-driven sessions"
148
+ end
149
+
150
+ def reject_oversized_prompt!(prompt)
151
+ prompt_bytesize = prompt.to_s.bytesize
152
+ max_length = @generation_config[:max_prompt_length] || @options[:max_prompt_length] || DEFAULT_MAX_PROMPT_LENGTH
153
+ return if prompt_bytesize <= max_length
154
+
155
+ raise Ace::LLM::ProviderError,
156
+ "Antigravity CLI prompt bytesize #{prompt_bytesize} exceeds configured limit #{max_length}. " \
157
+ "Use a provider with file/stdin prompt delivery or lower the prompt size."
158
+ end
159
+
160
+ def normalize_print_timeout(timeout)
161
+ value = timeout.to_s
162
+ return "#{value}s" if value.match?(/\A\d+\z/)
163
+
164
+ value
165
+ end
166
+
167
+ def execute_agy_command(cmd, working_dir:, options:)
168
+ timeout_val = options[:timeout] || @options[:timeout] || 120
169
+ Molecules::SafeCapture.call(
170
+ cmd,
171
+ timeout: timeout_val,
172
+ stdin_data: "",
173
+ chdir: working_dir,
174
+ env: options[:subprocess_env],
175
+ command_prefix: options[:subprocess_command_prefix],
176
+ provider_name: "Antigravity"
177
+ )
178
+ end
179
+
180
+ def parse_agy_response(stdout, stderr, status, prompt)
181
+ response = if stream_json_output?(stdout)
182
+ parse_stream_json(stdout)
183
+ else
184
+ parse_json_or_text(stdout)
185
+ end
186
+
187
+ error_message = response["error"].to_s
188
+ run_status = response.fetch("status", status.success? ? "SUCCESS" : "ERROR")
189
+ if !status.success? || run_status != "SUCCESS"
190
+ raise Ace::LLM::ProviderError, build_failure_message(error_message, stderr, stdout, run_status)
191
+ end
192
+
193
+ text = extract_response_text(response)
194
+ {
195
+ text: text,
196
+ metadata: build_metadata(response, text, prompt)
197
+ }
198
+ end
199
+
200
+ def stream_json_output?(stdout)
201
+ stdout.each_line.lazy.map(&:strip).reject(&:empty?).first.to_s.include?("\"event\"")
202
+ end
203
+
204
+ def parse_stream_json(stdout)
205
+ text = +""
206
+ result_payload = nil
207
+
208
+ stdout.each_line do |line|
209
+ line = line.strip
210
+ next if line.empty?
211
+
212
+ entry = JSON.parse(line)
213
+ case entry["event"]
214
+ when "step_update"
215
+ text << entry.dig("step_update", "text_delta").to_s
216
+ when "result"
217
+ result_payload = entry["result"] || {}
218
+ end
219
+ end
220
+
221
+ response = result_payload || {}
222
+ response["response"] = text unless text.empty? || response["response"]
223
+ response
224
+ rescue JSON::ParserError
225
+ {"response" => stdout.to_s.strip}
226
+ end
227
+
228
+ def parse_json_or_text(stdout)
229
+ JSON.parse(stdout)
230
+ rescue JSON::ParserError
231
+ {"response" => stdout.to_s.strip}
232
+ end
233
+
234
+ def build_failure_message(error_message, stderr, stdout, run_status)
235
+ detail = [error_message, stderr.to_s.strip, stdout.to_s.strip].reject(&:empty?).first
236
+ detail = run_status if detail.nil? || detail.empty?
237
+ "Antigravity CLI failed: #{detail}"
238
+ end
239
+
240
+ def extract_response_text(response)
241
+ response["response"] || response["text"] || response.dig("result", "response") || ""
242
+ end
243
+
244
+ def build_metadata(response, text, prompt)
245
+ usage = response["usage"] || {}
246
+ prompt_tokens = usage["input_tokens"] || (prompt.to_s.length / 4.0).ceil
247
+ output_tokens = usage["output_tokens"] || (text.to_s.length / 4.0).ceil
248
+ conversation_id = response["conversation_id"]
249
+
250
+ metadata = {
251
+ provider: "agy",
252
+ model: @model || DEFAULT_MODEL,
253
+ input_tokens: prompt_tokens,
254
+ output_tokens: output_tokens,
255
+ total_tokens: usage["total_tokens"] || prompt_tokens + output_tokens,
256
+ finish_reason: response["status"]&.downcase || "success",
257
+ timestamp: Time.now.utc.iso8601
258
+ }
259
+ metadata[:thinking_tokens] = usage["thinking_tokens"] if usage["thinking_tokens"]
260
+ metadata[:cache_read_tokens] = usage["cache_read_tokens"] if usage["cache_read_tokens"]
261
+ metadata[:duration_seconds] = response["duration_seconds"] if response["duration_seconds"]
262
+ metadata[:num_turns] = response["num_turns"] if response["num_turns"]
263
+ metadata[:conversation_id] = conversation_id if conversation_id
264
+ metadata[:session_id] = conversation_id if conversation_id
265
+ metadata
266
+ end
267
+
268
+ def handle_agy_error(error)
269
+ raise error
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -11,9 +11,10 @@ module Ace
11
11
  class AuthChecker
12
12
  # Check authentication for a specific provider
13
13
  # @param provider [String] Provider name (claude, codex, opencode, codexoss)
14
- # @return [Hash] Result with :authenticated and :message
14
+ # @return [Hash] Result with :authenticated, :ready, and :message
15
15
  def self.check(provider)
16
16
  case provider
17
+ when "agy" then check_agy
17
18
  when "claude" then check_claude
18
19
  when "codex" then check_codex
19
20
  when "opencode" then check_opencode
@@ -34,6 +35,21 @@ module Ace
34
35
  {authenticated: false, message: "Authentication check failed"}
35
36
  end
36
37
 
38
+ def self.check_agy
39
+ _, _, status = Open3.capture3("agy", "--help")
40
+ if status.success?
41
+ {
42
+ authenticated: false,
43
+ ready: true,
44
+ message: "Installed, but headless authentication is not verified (interactive agy auth may still need to be completed before the first headless run)"
45
+ }
46
+ else
47
+ {authenticated: false, ready: false, message: "Run: agy"}
48
+ end
49
+ rescue Errno::ENOENT, Errno::EACCES
50
+ {authenticated: false, ready: false, message: "Authentication check failed"}
51
+ end
52
+
37
53
  def self.check_codex
38
54
  _, _, status = Open3.capture3("codex", "--help")
39
55
  if status.success?
@@ -11,6 +11,13 @@ module Ace
11
11
  # Orchestrates provider detection and authentication checking
12
12
  class HealthChecker
13
13
  PROVIDERS = {
14
+ "agy" => {
15
+ name: "Antigravity",
16
+ provider: "agy",
17
+ check_cmd: ["agy", "--help"],
18
+ install_cmd: "curl -fsSL https://antigravity.google/cli/install.sh | bash",
19
+ install_url: "https://antigravity.google/product/antigravity-cli"
20
+ },
14
21
  "claude" => {
15
22
  name: "Claude Code",
16
23
  provider: "claude",
@@ -57,6 +64,7 @@ module Ace
57
64
  config: config,
58
65
  available: false,
59
66
  authenticated: false,
67
+ ready: false,
60
68
  version: nil,
61
69
  auth_status: "Not checked"
62
70
  }
@@ -67,6 +75,7 @@ module Ace
67
75
 
68
76
  auth_result = Atoms::AuthChecker.check(config[:provider])
69
77
  result[:authenticated] = auth_result[:authenticated]
78
+ result[:ready] = auth_result.fetch(:ready, auth_result[:authenticated])
70
79
  result[:auth_status] = auth_result[:message]
71
80
  end
72
81
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Linux normally reparents orphaned grandchildren to PID 1. Container
4
+ # entrypoints do not always reap them, leaving killed descendants as observable
5
+ # zombies. This supervisor becomes a child subreaper before spawning the provider
6
+ # command, so it owns and reaps the complete isolated command group before exit.
7
+ module Ace
8
+ module LLM
9
+ module Providers
10
+ module CLI
11
+ module Molecules
12
+ module ProcessSupervisor
13
+ PR_SET_CHILD_SUBREAPER = 36
14
+
15
+ module_function
16
+
17
+ def run(command)
18
+ enable_child_subreaper
19
+ ready_fd = Integer(ENV.delete("ACE_SAFE_CAPTURE_READY_FD"))
20
+ command_pid = Process.spawn(*command, pgroup: true, ready_fd => :close)
21
+ command_pgid = command_pid
22
+ IO.for_fd(ready_fd).tap { |io| io.write("1"); io.close }
23
+
24
+ Signal.trap("TERM") { terminate_group(command_pgid) }
25
+
26
+ _pid, status = Process.wait2(command_pid)
27
+ terminate_group(command_pgid)
28
+ reap_group(command_pgid)
29
+
30
+ exit(status.exitstatus || 128 + status.termsig)
31
+ end
32
+
33
+ def enable_child_subreaper
34
+ previous_deprecated = Warning[:deprecated]
35
+ Warning[:deprecated] = false
36
+ require "fiddle"
37
+ Warning[:deprecated] = previous_deprecated
38
+
39
+ prctl = Fiddle::Function.new(
40
+ Fiddle::Handle::DEFAULT["prctl"],
41
+ [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG],
42
+ Fiddle::TYPE_INT
43
+ )
44
+ raise "unable to become a child subreaper" unless prctl.call(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0).zero?
45
+ ensure
46
+ Warning[:deprecated] = previous_deprecated unless previous_deprecated.nil?
47
+ end
48
+
49
+ def terminate_group(pgid)
50
+ Process.kill("TERM", -pgid)
51
+ Process.kill("KILL", -pgid)
52
+ rescue Errno::ESRCH, Errno::EPERM
53
+ nil
54
+ end
55
+
56
+ def reap_group(pgid)
57
+ loop { Process.waitpid(-pgid) }
58
+ rescue Errno::ECHILD
59
+ nil
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ Ace::LLM::Providers::CLI::Molecules::ProcessSupervisor.run(ARGV) if $PROGRAM_NAME == __FILE__
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "rbconfig"
4
5
 
5
6
  module Ace
6
7
  module LLM
@@ -13,8 +14,9 @@ module Ace
13
14
  # which causes "stream closed in another thread (IOError)" when the
14
15
  # timeout fires while Open3's internal reader threads hold pipe handles.
15
16
  #
16
- # Uses Open3.popen3 + Process.kill so the child process is terminated
17
- # directly — no thread interruption, no IOError.
17
+ # Uses Open3.popen3 and, on Linux, a dedicated child-subreaper supervisor
18
+ # so the complete command tree is terminated and reaped without thread
19
+ # interruption or closed-stream races.
18
20
  class SafeCapture
19
21
  # @param cmd [Array<String>] Command arguments
20
22
  # @param timeout [Integer] Timeout in seconds
@@ -23,7 +25,7 @@ module Ace
23
25
  # @param env [Hash, nil] Environment variables (merged with current env)
24
26
  # @param provider_name [String] Provider name for error messages
25
27
  # @param isolate_process_group [Boolean] Spawn subprocess in isolated process group
26
- # @param cleanup_group_on_exit [Boolean] Best-effort cleanup of descendants on success
28
+ # @param cleanup_group_on_exit [Boolean] Clean up descendants on success
27
29
  # @return [Array(String, String, Process::Status)] [stdout, stderr, status]
28
30
  # @raise [Ace::LLM::ProviderError] on timeout
29
31
  def self.call(cmd, timeout:, stdin_data: nil, chdir: nil, env: nil, provider_name: "CLI",
@@ -35,9 +37,19 @@ module Ace
35
37
  opts[:pgroup] = true if isolate_process_group
36
38
 
37
39
  full_cmd = Array(command_prefix) + cmd
38
- args = env ? [env, *full_cmd] : full_cmd
40
+ supervised = isolate_process_group && cleanup_group_on_exit && RUBY_PLATFORM.include?("linux")
41
+ full_cmd = supervisor_command(full_cmd) if supervised
42
+ ready_reader, ready_writer = IO.pipe if supervised
43
+ opts[ready_writer.fileno] = ready_writer if supervised
44
+ spawn_env = env&.dup || {}
45
+ spawn_env["ACE_SAFE_CAPTURE_READY_FD"] = ready_writer.fileno.to_s if supervised
46
+ args = spawn_env.empty? ? full_cmd : [spawn_env, *full_cmd]
39
47
 
40
48
  Open3.popen3(*args, **opts) do |stdin, stdout, stderr, wait_thr|
49
+ ready_writer&.close
50
+ ready_reader&.read(1)
51
+ ready_reader&.close
52
+
41
53
  pid = wait_thr.pid
42
54
  pgid = safe_getpgid(pid)
43
55
  debug_log(provider_name, "spawn pid=#{pid} pgid=#{pgid || "n/a"}")
@@ -56,8 +68,13 @@ module Ace
56
68
 
57
69
  unless wait_thr.join(normalized_timeout)
58
70
  # Timeout: kill subprocess group (and descendants), then clean up
59
- terminate_subprocess_tree(pid: pid, pgid: pgid, provider_name: provider_name)
60
- wait_thr.join(5)
71
+ terminate_subprocess_tree(
72
+ pid: pid, pgid: pgid, provider_name: provider_name, supervised: supervised
73
+ )
74
+ unless wait_thr.join(5)
75
+ terminate_group_or_pid("KILL", pid, pgid)
76
+ wait_thr.join(5)
77
+ end
61
78
 
62
79
  stdout.close unless stdout.closed?
63
80
  stderr.close unless stderr.closed?
@@ -70,12 +87,15 @@ module Ace
70
87
  end
71
88
 
72
89
  status = wait_thr.value
73
- if isolate_process_group && cleanup_group_on_exit
90
+ if isolate_process_group && cleanup_group_on_exit && !supervised
74
91
  terminate_descendants_after_success(pid: pid, pgid: pgid, provider_name: provider_name)
75
92
  end
76
93
 
77
94
  [out_reader.value, err_reader.value, status]
78
95
  end
96
+ ensure
97
+ ready_reader&.close unless ready_reader&.closed?
98
+ ready_writer&.close unless ready_writer&.closed?
79
99
  end
80
100
 
81
101
  class << self
@@ -99,21 +119,24 @@ module Ace
99
119
  raise ArgumentError, "timeout must be a positive numeric value, got #{value.inspect}"
100
120
  end
101
121
 
102
- def terminate_subprocess_tree(pid:, pgid:, provider_name:)
122
+ def supervisor_command(command)
123
+ supervisor = File.expand_path("process_supervisor.rb", __dir__)
124
+ [RbConfig.ruby, "-W0", supervisor, *command]
125
+ end
126
+
127
+ def terminate_subprocess_tree(pid:, pgid:, provider_name:, supervised: false)
103
128
  debug_log(provider_name, "timeout cleanup pid=#{pid} pgid=#{pgid || "n/a"}")
104
129
  terminate_group_or_pid("TERM", pid, pgid)
105
- sleep(0.1)
106
- terminate_group_or_pid("KILL", pid, pgid)
130
+ terminate_group_or_pid("KILL", pid, pgid) unless supervised
107
131
  end
108
132
 
109
133
  def terminate_descendants_after_success(pid:, pgid:, provider_name:)
110
134
  return unless pgid
111
- return unless group_alive?(pgid)
112
135
 
113
136
  debug_log(provider_name, "post-exit cleanup pgid=#{pgid}")
114
- terminate_group_or_pid("TERM", pid, pgid)
115
- sleep(0.05)
116
- terminate_group_or_pid("KILL", pid, pgid) if group_alive?(pgid)
137
+ safe_kill_group(pgid, "TERM")
138
+ safe_kill_group(pgid, "KILL")
139
+ reap_terminated_group(pgid)
117
140
  end
118
141
 
119
142
  def terminate_group_or_pid(signal, pid, pgid)
@@ -122,6 +145,13 @@ module Ace
122
145
  else
123
146
  Process.kill(signal, pid)
124
147
  end
148
+ true
149
+ rescue Errno::ESRCH, Errno::EPERM
150
+ nil
151
+ end
152
+
153
+ def safe_kill_group(pgid, signal)
154
+ Process.kill(signal, -pgid)
125
155
  rescue Errno::ESRCH, Errno::EPERM
126
156
  nil
127
157
  end
@@ -132,13 +162,10 @@ module Ace
132
162
  nil
133
163
  end
134
164
 
135
- def group_alive?(pgid)
136
- Process.kill(0, -pgid)
137
- true
138
- rescue Errno::ESRCH
139
- false
140
- rescue Errno::EPERM
141
- true
165
+ def reap_terminated_group(pgid)
166
+ loop { Process.waitpid(-pgid) }
167
+ rescue Errno::ECHILD
168
+ nil
142
169
  end
143
170
 
144
171
  def debug_log(provider_name, message)
@@ -27,7 +27,7 @@ module Ace
27
27
  "pi"
28
28
  end
29
29
 
30
- DEFAULT_MODEL = "zai/glm-4.7"
30
+ DEFAULT_MODEL = "zai/glm-5.3-flash"
31
31
 
32
32
  def initialize(model: nil, **options)
33
33
  @model = model || DEFAULT_MODEL
@@ -67,7 +67,9 @@ module Ace
67
67
  # List available Pi models
68
68
  def list_models
69
69
  [
70
- {id: "zai/glm-4.7", name: "GLM 4.7", description: "ZAI default model", context_size: 128_000},
70
+ {id: "zai/glm-5.3", name: "GLM 5.3", description: "ZAI flagship model", context_size: 1_000_000},
71
+ {id: "zai/glm-5.3-flash", name: "GLM 5.3 Flash", description: "ZAI routine default", context_size: 1_000_000},
72
+ {id: "zai/glm-5.3-highspeed", name: "GLM 5.3 Highspeed", description: "ZAI low-latency model", context_size: 1_000_000},
71
73
  {id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6", description: "Anthropic flagship", context_size: 200_000},
72
74
  {id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5", description: "Anthropic balanced", context_size: 200_000},
73
75
  {id: "anthropic/claude-haiku-4-5", name: "Claude Haiku 4.5", description: "Anthropic fast", context_size: 200_000},
@@ -4,7 +4,7 @@ module Ace
4
4
  module LLM
5
5
  module Providers
6
6
  module CLI
7
- VERSION = "0.31.7"
7
+ VERSION = "0.33.0"
8
8
  end
9
9
  end
10
10
  end
@@ -22,6 +22,7 @@ module Ace
22
22
  def require_cli_providers
23
23
  # Require each CLI provider client
24
24
  providers = %w[
25
+ agy_client
25
26
  claude_code_client
26
27
  claude_oai_client
27
28
  codex_client
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-llm-providers-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.7
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-12 00:00:00.000000000 Z
10
+ date: 2026-09-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-llm
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.38'
18
+ version: '0.39'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.38'
25
+ version: '0.39'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rake
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -74,6 +74,7 @@ executables:
74
74
  extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
+ - ".ace-defaults/llm/providers/agy.yml"
77
78
  - ".ace-defaults/llm/providers/claude.yml"
78
79
  - ".ace-defaults/llm/providers/claudeoai.yml"
79
80
  - ".ace-defaults/llm/providers/codex.yml"
@@ -88,6 +89,7 @@ files:
88
89
  - Rakefile
89
90
  - exe/ace-llm-providers-cli-check
90
91
  - lib/ace/llm/providers/cli.rb
92
+ - lib/ace/llm/providers/cli/agy_client.rb
91
93
  - lib/ace/llm/providers/cli/atoms/args_normalizer.rb
92
94
  - lib/ace/llm/providers/cli/atoms/auth_checker.rb
93
95
  - lib/ace/llm/providers/cli/atoms/command_formatters.rb
@@ -109,6 +111,7 @@ files:
109
111
  - lib/ace/llm/providers/cli/codex_oai_client.rb
110
112
  - lib/ace/llm/providers/cli/gemini_client.rb
111
113
  - lib/ace/llm/providers/cli/molecules/health_checker.rb
114
+ - lib/ace/llm/providers/cli/molecules/process_supervisor.rb
112
115
  - lib/ace/llm/providers/cli/molecules/safe_capture.rb
113
116
  - lib/ace/llm/providers/cli/molecules/session_finder.rb
114
117
  - lib/ace/llm/providers/cli/molecules/skill_name_reader.rb