agent-cli-runtime 0.1.1 → 0.2.4

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.
@@ -103,11 +103,44 @@ module AgentCliRuntime
103
103
  home_path(home, ".grok", "auth.json")
104
104
  end
105
105
 
106
+ def opencode_auth_path(home:, env:)
107
+ data_home = env["XDG_DATA_HOME"]
108
+ if !data_home.to_s.empty?
109
+ unless File.absolute_path?(data_home)
110
+ raise ArgumentError, "XDG_DATA_HOME must be absolute"
111
+ end
112
+
113
+ return File.join(data_home, "opencode", "auth.json")
114
+ end
115
+
116
+ home_path(home, ".local", "share", "opencode", "auth.json")
117
+ end
118
+
119
+ def opencode_model_arguments(model)
120
+ route = Route.parse(model)
121
+ [ "--model", route.to_s ]
122
+ end
123
+
124
+ OPENCODE_VARIANTS = %w[minimal low medium high xhigh max].freeze
125
+
126
+ def opencode_variant_arguments(variant)
127
+ normalized = variant.to_s
128
+ unless OPENCODE_VARIANTS.include?(normalized)
129
+ raise ArgumentError,
130
+ "unsupported OpenCode variant #{variant.inspect}; expected one of " \
131
+ "#{OPENCODE_VARIANTS.join(', ')}"
132
+ end
133
+
134
+ [ "--variant", normalized ]
135
+ end
136
+
106
137
  CLAUDE = Profile.new(
107
138
  name: :claude,
108
139
  bin_default: "claude",
109
140
  env_bin_override_keys: %w[AGENT_CLI_RUNTIME_CLAUDE_BIN HIVE_CLAUDE_BIN],
110
141
  headless_flag: "-p",
142
+ # Print mode reads stdin; keep large review prompts out of exec arguments.
143
+ prompt_style: :piped_stdin,
111
144
  permission_skip_flag: "--dangerously-skip-permissions",
112
145
  add_dir_flag: "--add-dir",
113
146
  tool_scope_flags: {
@@ -125,6 +158,7 @@ module AgentCliRuntime
125
158
  effort_argument_builder: ->(effort) { [ "--effort", effort ] },
126
159
  launcher_identity: "claude-code/v1",
127
160
  usage_extractor: UsageExtractors::CLAUDE,
161
+ error_extractor: ErrorExtractors::CLAUDE,
128
162
  credential_environment_keys: %w[
129
163
  ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_API_KEY
130
164
  ],
@@ -189,12 +223,17 @@ module AgentCliRuntime
189
223
  bin_default: "pi",
190
224
  env_bin_override_keys: %w[AGENT_CLI_RUNTIME_PI_BIN HIVE_PI_BIN],
191
225
  headless_flag: "-p",
226
+ # Pi reads a non-TTY stdin stream into its initial message without an
227
+ # argv placeholder. Keeping the prompt out of argv also avoids Linux's
228
+ # per-argument size limit on real implementation plans.
229
+ prompt_style: :piped_stdin,
192
230
  output_format_flags: [ "--mode", "json", "--no-session" ],
193
231
  version_flag: "--version",
194
232
  min_version: "0.70.2",
195
233
  model_argument_builder: ->(model) { [ "--model", model ] },
196
234
  launcher_identity: "pi-coding-agent/v1",
197
235
  usage_extractor: UsageExtractors::PI,
236
+ error_extractor: ErrorExtractors::PI,
198
237
  credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
199
238
  configuration_environment_key: "PI_CODING_AGENT_DIR",
200
239
  default_configuration_directory: ".pi/agent",
@@ -216,9 +255,18 @@ module AgentCliRuntime
216
255
  name: :grok,
217
256
  bin_default: "grok",
218
257
  env_bin_override_keys: %w[AGENT_CLI_RUNTIME_GROK_BIN HIVE_GROK_BIN],
219
- headless_flag: "-p",
220
- prompt_style: :headless_flag_value,
258
+ # Grok's Unix CLI reads a prompt file; stdin already has a managed lifetime.
259
+ headless_flag: "--prompt-file=/dev/stdin",
260
+ prompt_style: :piped_stdin,
221
261
  permission_skip_flag: "--always-approve",
262
+ # Grok confines the filesystem natively, the same shape codex uses.
263
+ # `workspace` limits writes to the working directory, `read-only`
264
+ # forbids them entirely; both are built-in profiles (custom ones extend
265
+ # them from ~/.grok/sandbox.toml). `--always-approve` suppresses the
266
+ # interactive approval prompt, which a headless reviewer can never answer
267
+ # — the sandbox, not the prompt, is what actually bounds the agent.
268
+ workspace_write_flags: [ "--sandbox", "workspace", "--always-approve" ],
269
+ read_only_flags: [ "--sandbox", "read-only", "--always-approve" ],
222
270
  output_format_flags: [ "--output-format", "streaming-json" ],
223
271
  version_flag: "--version",
224
272
  min_version: "0.2.90",
@@ -242,7 +290,54 @@ module AgentCliRuntime
242
290
  end
243
291
  )
244
292
 
245
- PROFILES = [ CLAUDE, CODEX, PI, GROK ].to_h do |profile|
293
+ OPENCODE = Profile.new(
294
+ name: :opencode,
295
+ bin_default: "opencode",
296
+ env_bin_override_keys: %w[
297
+ AGENT_CLI_RUNTIME_OPENCODE_BIN HIVE_OPENCODE_BIN
298
+ ],
299
+ headless_flag: "run",
300
+ output_format_flags: [ "--format", "json" ],
301
+ version_flag: "--version",
302
+ # `opencode run` reads a non-TTY stdin stream as the initial message.
303
+ # Keep implementation-sized prompts out of one argv element: Linux
304
+ # rejects a single argument around 128 KiB with E2BIG even when the
305
+ # complete argv remains far below ARG_MAX.
306
+ prompt_style: :piped_stdin,
307
+ # Bun-backed OpenCode startup can exceed the generic 10-second bound
308
+ # under sustained host I/O even though the executable is healthy.
309
+ version_check_timeout_sec: 30,
310
+ min_version: "1.18.16",
311
+ model_argument_builder: ->(model) { opencode_model_arguments(model) },
312
+ effort_argument_builder:
313
+ ->(variant) { opencode_variant_arguments(variant) },
314
+ launcher_identity: "opencode-cli/v1",
315
+ credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
316
+ configuration_environment_key: "OPENCODE_CONFIG_DIR",
317
+ default_configuration_directory: ".config/opencode",
318
+ permission_policy_required: true,
319
+ error_extractor: ErrorExtractors::OPENCODE,
320
+ result_parser: OpenCode::ResultParser,
321
+ cli_capabilities: {
322
+ json_events: [ "run", "--format" ],
323
+ working_directory: [ "run", "--dir" ],
324
+ model_variant: [ "run", "--variant" ],
325
+ pure: [ "run", "--pure" ],
326
+ sanitized_export: [ "export", "--sanitize" ]
327
+ },
328
+ auth_configuration_probe: lambda do |home:, env:|
329
+ if env_configured?(env, *PI_CREDENTIAL_ENVIRONMENT_KEYS)
330
+ AuthConfiguration.new(status: :configured, source: "environment")
331
+ else
332
+ auth_from_file(
333
+ opencode_auth_path(home:, env:),
334
+ source: "opencode auth.json"
335
+ )
336
+ end
337
+ end
338
+ )
339
+
340
+ PROFILES = [ CLAUDE, CODEX, PI, GROK, OPENCODE ].to_h do |profile|
246
341
  [ profile.name, profile ]
247
342
  end.freeze
248
343
  PROVIDER_ORDER = PROFILES.keys.freeze
@@ -46,12 +46,23 @@ module AgentCliRuntime
46
46
  end
47
47
  argv.concat(request.trusted_cli_arguments)
48
48
  argv.concat(profile.output_format_flags) if request.include_output_format
49
- argv << (prompt_style == :stdin ? "-" : request.prompt) unless
50
- prompt_style == :headless_flag_value
49
+ stdin_data =
50
+ case prompt_style
51
+ when :positional
52
+ argv << request.prompt
53
+ nil
54
+ when :headless_flag_value
55
+ nil
56
+ when :stdin
57
+ argv << "-"
58
+ request.prompt
59
+ when :piped_stdin
60
+ request.prompt
61
+ end
51
62
 
52
63
  CompiledInvocation.new(
53
64
  argv: request.command_prefix + argv,
54
- stdin_data: prompt_style == :stdin ? request.prompt : nil,
65
+ stdin_data:,
55
66
  provider: profile.name,
56
67
  launcher_identity: profile.launcher_identity,
57
68
  capability_evidence: evidence
@@ -63,9 +74,13 @@ module AgentCliRuntime
63
74
  compilation_error!(profile, e)
64
75
  end
65
76
 
66
- def prepare!(profile)
77
+ def prepare!(profile, env: ENV)
78
+ if profile.is_a?(OpenCodePreparationRequest)
79
+ return OpenCode::Overlay.prepare!(profile, env:)
80
+ end
81
+
67
82
  resolved = Profiles.resolve(profile)
68
- result = Probe.call(resolved)
83
+ result = Probe.call(resolved, env:)
69
84
  return result if result.ready
70
85
 
71
86
  evidence = unsupported_evidence(
@@ -103,6 +118,51 @@ module AgentCliRuntime
103
118
  end
104
119
  end
105
120
 
121
+ # A provider failure seen mid-stream, normalized to
122
+ # {kind:, provider:, status_code:, message:}, or nil when the event is
123
+ # clean. Callers use this to tell a provider-side stop (quota, credit
124
+ # ceiling, rate limit, model output truncation) apart from an agent that
125
+ # genuinely produced nothing: several CLIs report the former on the stream
126
+ # and still exit zero.
127
+ def extract_provider_error(profile, event)
128
+ resolved = Profiles.resolve(profile)
129
+ extracted = resolved.extract_error_event(event)
130
+ return nil if extracted.nil?
131
+
132
+ kind, text = normalize_extracted_error(extracted)
133
+ return nil if text.nil? || text.strip.empty?
134
+
135
+ {
136
+ kind: kind,
137
+ provider: resolved.name,
138
+ status_code: status_code_from(text),
139
+ message: Redactor.diagnostic(text)
140
+ }.freeze
141
+ end
142
+
143
+ def normalize_extracted_error(extracted)
144
+ return [ extracted.kind, extracted.message ] if extracted.is_a?(ExtractedFailure)
145
+ return [ failure_kind_from(extracted), extracted ] if extracted.is_a?(String)
146
+
147
+ [ nil, nil ]
148
+ end
149
+ private_class_method :normalize_extracted_error
150
+
151
+ def failure_kind_from(text)
152
+ status = status_code_from(text)
153
+ return :provider_limit if status == 402
154
+ return :rate_limited if status == 429
155
+
156
+ normalized = text.to_s.downcase
157
+ return :rate_limited if normalized.match?(/\brate[\s_-]*limit(?:ed|s)?\b/)
158
+ return :provider_limit if normalized.match?(
159
+ /\b(?:quota|billing|credits?|tokens?[\s_-]*limit|usage[\s_-]*limit)\b/
160
+ )
161
+
162
+ :provider_error
163
+ end
164
+ private_class_method :failure_kind_from
165
+
106
166
  def observe(profile, result)
107
167
  resolved = Profiles.resolve(profile)
108
168
  raw = result.is_a?(Hash) ? result : {}
@@ -121,6 +181,16 @@ module AgentCliRuntime
121
181
  )
122
182
  end
123
183
 
184
+ def parse_run(profile, stdout:)
185
+ Profiles.resolve(profile).parse_run(stdout)
186
+ end
187
+
188
+ def normalize(profile, captured, requested_route:)
189
+ Profiles.resolve(profile).normalize_captured_result(
190
+ captured, requested_route:
191
+ )
192
+ end
193
+
124
194
  def supported_evidence(profile, capability, arguments = [])
125
195
  CapabilityEvidence.new(
126
196
  capability: capability,
@@ -253,22 +323,57 @@ module AgentCliRuntime
253
323
  end
254
324
  private_class_method :tool_csv
255
325
 
326
+ # Providers prefix the status onto the text ("402: {...}") or carry it in
327
+ # the embedded payload. Either is enough to classify without matching on
328
+ # human-readable wording, which differs per provider and changes freely.
329
+ def status_code_from(text)
330
+ value = text[/\A\s*(\d{3})\s*:/, 1] ||
331
+ text[/"(?:code|http_status)"\s*:\s*(\d{3})\b/, 1]
332
+ code = value.to_i
333
+ code.between?(100, 599) ? code : nil
334
+ end
335
+ private_class_method :status_code_from
336
+
256
337
  def normalize_usage(usage)
257
338
  return nil unless usage.is_a?(Hash)
258
339
 
259
- input = usage.key?(:input) ? usage[:input] : usage["input"]
260
- output = usage.key?(:output) ? usage[:output] : usage["output"]
261
- cached = usage.key?(:cached) ? usage[:cached] : usage["cached"]
262
- model = usage.key?(:model) ? usage[:model] : usage["model"]
340
+ value = ->(key) { usage.key?(key) ? usage[key] : usage[key.to_s] }
263
341
  {
264
- input: [ input.to_i, 0 ].max,
265
- output: [ output.to_i, 0 ].max,
266
- cached: [ cached.to_i, 0 ].max,
267
- model: model&.to_s&.dup&.freeze
342
+ input: normalized_count(value.call(:input)),
343
+ output: normalized_count(value.call(:output)),
344
+ cached: normalized_count(value.call(:cached)),
345
+ cache_read: normalized_count(value.call(:cache_read)),
346
+ cache_write: normalized_count(value.call(:cache_write)),
347
+ reasoning: normalized_count(value.call(:reasoning)),
348
+ input_includes_cache_read: normalized_boolean(value.call(:input_includes_cache_read)),
349
+ input_includes_cache_write: normalized_boolean(value.call(:input_includes_cache_write)),
350
+ output_includes_reasoning: normalized_boolean(value.call(:output_includes_reasoning)),
351
+ model: value.call(:model)&.to_s&.dup&.freeze,
352
+ # Carried as a float: this is money, and normalized_count would round
353
+ # a fraction-of-a-cent charge to zero.
354
+ provider_reported_cost: normalized_cost(value.call(:provider_reported_cost))
268
355
  }.freeze
269
356
  end
270
357
  private_class_method :normalize_usage
271
358
 
359
+ def normalized_cost(value)
360
+ return nil unless value.is_a?(Numeric)
361
+
362
+ cost = value.to_f
363
+ cost if cost >= 0 && cost.finite?
364
+ end
365
+ private_class_method :normalized_cost
366
+
367
+ def normalized_count(value)
368
+ value.nil? ? nil : [ value.to_i, 0 ].max
369
+ end
370
+ private_class_method :normalized_count
371
+
372
+ def normalized_boolean(value)
373
+ value if value == true || value == false
374
+ end
375
+ private_class_method :normalized_boolean
376
+
272
377
  def unsupported!(profile, capability, diagnostic)
273
378
  evidence = unsupported_evidence(profile, capability, diagnostic)
274
379
  raise UnsupportedCapability.new(
@@ -7,9 +7,9 @@ module AgentCliRuntime
7
7
 
8
8
  usage = usage_hash(event)
9
9
  if event["type"] == "result" && usage
10
- usage_result(event, usage, model_from(event))
10
+ usage_result(event, usage, model_from(event), provider: :claude)
11
11
  elsif event["type"] == "stream_event" && usage
12
- usage_result(event, usage, model_from(event))
12
+ usage_result(event, usage, model_from(event), provider: :claude)
13
13
  end
14
14
  end
15
15
 
@@ -17,21 +17,21 @@ module AgentCliRuntime
17
17
  next nil unless event.is_a?(Hash)
18
18
 
19
19
  usage = usage_hash(event)
20
- usage_result(event, usage, model_from(event)) if usage
20
+ usage_result(event, usage, model_from(event), provider: :codex) if usage
21
21
  end
22
22
 
23
23
  PI = lambda do |event|
24
24
  next nil unless event.is_a?(Hash)
25
25
 
26
26
  usage = usage_hash(event)
27
- usage_result(event, usage, model_from(event)) if usage
27
+ usage_result(event, usage, model_from(event), provider: :pi) if usage
28
28
  end
29
29
 
30
30
  GROK = lambda do |event|
31
31
  next nil unless event.is_a?(Hash)
32
32
 
33
33
  usage = usage_hash(event)
34
- usage_result(event, usage, model_from(event)) if usage
34
+ usage_result(event, usage, model_from(event), provider: :grok) if usage
35
35
  end
36
36
 
37
37
  def usage_hash(event)
@@ -40,6 +40,9 @@ module AgentCliRuntime
40
40
  event["token_usage"],
41
41
  event.dig("event", "usage"),
42
42
  event.dig("event", "message", "usage"),
43
+ # pi reports usage on the assistant message itself, not under an
44
+ # "event" envelope. Without this every pi run is unmetered.
45
+ event.dig("message", "usage"),
43
46
  event.dig("info", "usage"),
44
47
  event.dig("info", "total_token_usage"),
45
48
  event.dig("response", "usage"),
@@ -48,42 +51,126 @@ module AgentCliRuntime
48
51
  candidates.find { |value| value.is_a?(Hash) }
49
52
  end
50
53
 
51
- def usage_result(event, usage, model)
54
+ def usage_result(event, usage, model, provider:)
55
+ cache_read = cache_read_tokens(usage)
56
+ cache_write = cache_write_tokens(usage)
57
+ aggregate_cached = aggregate_cached_tokens(usage)
58
+ reasoning = optional_token_count(
59
+ usage, "reasoning_tokens", "reasoningTokens"
60
+ )
61
+ reasoning ||= optional_token_count(
62
+ nested_hash(usage, "completion_tokens_details"),
63
+ "reasoning_tokens", "reasoningTokens"
64
+ )
52
65
  {
53
- input: token_count(
54
- usage, "input_tokens", "inputTokens", "prompt_tokens", "promptTokens"
66
+ # The bare "input"/"output" spellings are pi's and are matched last,
67
+ # so a provider using an explicit *_tokens key keeps its own reading.
68
+ input: optional_token_count(
69
+ usage, "input_tokens", "inputTokens", "prompt_tokens", "promptTokens",
70
+ "input"
55
71
  ),
56
- output: token_count(
72
+ output: optional_token_count(
57
73
  usage, "output_tokens", "outputTokens", "completion_tokens",
58
- "completionTokens"
74
+ "completionTokens", "output"
59
75
  ),
60
- cached: cached_tokens(usage),
61
- model: model || model_from_usage(usage) || model_from(event)
76
+ cached: aggregate_cached || complete_cached(cache_read, cache_write),
77
+ cache_read: cache_read,
78
+ cache_write: cache_write,
79
+ reasoning: reasoning,
80
+ input_includes_cache_read:
81
+ inclusion_or_inferred(
82
+ usage, "input_includes_cache_read",
83
+ inferred_input_cache_inclusion(provider, cache_read)
84
+ ),
85
+ input_includes_cache_write:
86
+ inclusion_or_inferred(
87
+ usage, "input_includes_cache_write",
88
+ inferred_input_cache_inclusion(provider, cache_write)
89
+ ),
90
+ output_includes_reasoning:
91
+ inclusion_or_inferred(
92
+ usage, "output_includes_reasoning",
93
+ inferred_reasoning_inclusion(provider, reasoning)
94
+ ),
95
+ model: model || model_from_usage(usage) || model_from(event),
96
+ provider_reported_cost: reported_cost(usage)
62
97
  }
63
98
  end
64
99
 
65
- def cached_tokens(usage)
66
- direct = token_count(
100
+ # A charge the provider itself reported for this turn, in USD. This is an
101
+ # observed amount, not an estimate from the pricing catalog, and it is the
102
+ # only cost available for a model the catalog does not carry — an
103
+ # OpenRouter-routed model, for one. OpenRouter sends a per-category
104
+ # breakdown carrying a total; other providers may send a scalar.
105
+ def reported_cost(usage)
106
+ value = usage["cost"] || usage["total_cost"] || usage["totalCost"]
107
+ value = value["total"] || value["total_cost"] if value.is_a?(Hash)
108
+ return nil unless value.is_a?(Numeric) || value.is_a?(String)
109
+
110
+ cost = begin
111
+ Float(value)
112
+ rescue ArgumentError, TypeError
113
+ nil
114
+ end
115
+ cost if cost && cost >= 0 && cost.finite?
116
+ end
117
+
118
+ def aggregate_cached_tokens(usage)
119
+ optional_token_count(
67
120
  usage, "cached", "cached_tokens", "cachedTokens",
68
121
  "cached_input_tokens", "cachedInputTokens"
69
122
  )
70
- return direct if direct.positive?
123
+ end
124
+
125
+ def cache_read_tokens(usage)
126
+ optional_token_count(
127
+ usage, "cache_read_input_tokens", "cacheReadInputTokens"
128
+ ) || optional_token_count(
129
+ nested_hash(usage, "prompt_tokens_details"), "cached_tokens", "cachedTokens"
130
+ ) || optional_token_count(
131
+ nested_hash(usage, "input_tokens_details"), "cached_tokens", "cachedTokens"
132
+ ) || optional_token_count(usage, "cacheRead")
133
+ end
134
+
135
+ def cache_write_tokens(usage)
136
+ optional_token_count(
137
+ usage, "cache_creation_input_tokens", "cacheCreationInputTokens",
138
+ "cache_write_input_tokens", "cacheWriteInputTokens", "cacheWrite"
139
+ )
140
+ end
71
141
 
72
- token_count(usage, "cache_read_input_tokens", "cacheReadInputTokens") +
73
- token_count(usage, "cache_creation_input_tokens", "cacheCreationInputTokens") +
74
- token_count(
75
- nested_hash(usage, "prompt_tokens_details"), "cached_tokens", "cachedTokens"
76
- ) +
77
- token_count(
78
- nested_hash(usage, "input_tokens_details"), "cached_tokens", "cachedTokens"
79
- )
142
+ def complete_cached(cache_read, cache_write)
143
+ return nil if cache_read.nil? || cache_write.nil?
144
+
145
+ cache_read + cache_write
80
146
  end
81
147
 
82
- def token_count(hash, *keys)
83
- return 0 unless hash.is_a?(Hash)
148
+ def inferred_input_cache_inclusion(provider, value)
149
+ return nil if value.nil?
150
+ return false if provider == :claude
151
+ return true if provider == :codex
152
+
153
+ nil
154
+ end
155
+
156
+ def inferred_reasoning_inclusion(provider, value)
157
+ return nil if value.nil?
158
+
159
+ provider == :codex ? true : nil
160
+ end
161
+
162
+ def inclusion_or_inferred(hash, key, inferred)
163
+ return inferred unless hash.is_a?(Hash) && hash.key?(key)
164
+
165
+ value = hash[key]
166
+ value if value == true || value == false
167
+ end
168
+
169
+ def optional_token_count(hash, *keys)
170
+ return nil unless hash.is_a?(Hash)
84
171
 
85
172
  key = keys.find { |candidate| hash.key?(candidate) }
86
- key ? hash[key].to_i : 0
173
+ key ? hash[key].to_i : nil
87
174
  end
88
175
 
89
176
  def nested_hash(hash, key)
@@ -92,11 +179,6 @@ module AgentCliRuntime
92
179
  end
93
180
 
94
181
  def model_from(event)
95
- model_usage = event["modelUsage"]
96
- if model_usage.is_a?(Hash) && !model_usage.empty?
97
- return model_usage.keys.first.to_s
98
- end
99
-
100
182
  candidates = [
101
183
  event["model"],
102
184
  event.dig("message", "model"),
@@ -104,7 +186,12 @@ module AgentCliRuntime
104
186
  event.dig("response", "model"),
105
187
  event.dig("item", "model")
106
188
  ]
107
- candidates.find { |value| !value.to_s.empty? }&.to_s
189
+ explicit = candidates.find { |value| !value.to_s.empty? }
190
+ return explicit.to_s if explicit
191
+
192
+ # Run totals may include helper models; their order is not agent identity.
193
+ model_usage = event["modelUsage"]
194
+ model_usage.keys.first.to_s if model_usage.is_a?(Hash) && model_usage.size == 1
108
195
  end
109
196
 
110
197
  def model_from_usage(usage)