agent-cli-runtime 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,7 +27,8 @@ module AgentCliRuntime
27
27
  :effort_argument_builder, :launcher_identity,
28
28
  :cli_capabilities, :declared_capability_support,
29
29
  :credential_environment_keys, :configuration_environment_key,
30
- :default_configuration_directory
30
+ :default_configuration_directory,
31
+ :permission_policy_required, :result_parser
31
32
 
32
33
  def initialize(name:, bin_default:, headless_flag:, version_flag:,
33
34
  env_bin_override_keys: [], permission_skip_flag: nil,
@@ -39,7 +40,8 @@ module AgentCliRuntime
39
40
  usage_extractor: nil, auth_configuration_probe: nil,
40
41
  cli_capabilities: {}, raw_cli_arguments_supported: false,
41
42
  credential_environment_keys: [], configuration_environment_key: nil,
42
- default_configuration_directory: nil)
43
+ default_configuration_directory: nil,
44
+ permission_policy_required: false, result_parser: nil)
43
45
  normalized_prompt_style = prompt_style.to_sym
44
46
  unless PROMPT_STYLES.include?(normalized_prompt_style)
45
47
  raise ArgumentError,
@@ -78,6 +80,8 @@ module AgentCliRuntime
78
80
  @default_configuration_directory = optional_relative_directory(
79
81
  default_configuration_directory
80
82
  )
83
+ @permission_policy_required = permission_policy_required == true
84
+ @result_parser = result_parser
81
85
  @declared_capability_support = build_declared_capability_support
82
86
  freeze
83
87
  end
@@ -102,6 +106,10 @@ module AgentCliRuntime
102
106
  raise ArgumentError,
103
107
  "agent profile #{@name.inspect} cannot enforce read-only sandboxing"
104
108
  end
109
+ if permission_mode.nil? && @permission_policy_required
110
+ raise ConfigurationError,
111
+ "agent profile #{@name.inspect} requires an explicit OpenCode permission policy"
112
+ end
105
113
  return [] unless @permission_skip_flag
106
114
  return [ @permission_skip_flag ] unless @name == :claude && permission_mode
107
115
  return [ @permission_skip_flag ] if permission_mode == "bypassPermissions"
@@ -213,6 +221,26 @@ module AgentCliRuntime
213
221
  nil
214
222
  end
215
223
 
224
+ def parse_run(stdout)
225
+ unless @result_parser
226
+ raise UnsupportedCapability,
227
+ "agent profile #{@name.inspect} has no strict result parser"
228
+ end
229
+
230
+ @result_parser.parse_run(stdout)
231
+ end
232
+
233
+ def normalize_captured_result(captured, requested_route:)
234
+ unless @result_parser
235
+ raise UnsupportedCapability,
236
+ "agent profile #{@name.inspect} has no strict result parser"
237
+ end
238
+
239
+ @result_parser.normalize(
240
+ captured, requested_route: requested_route, profile: self
241
+ )
242
+ end
243
+
216
244
  def configuration_directory(home: nil, env: ENV)
217
245
  if @configuration_environment_key
218
246
  configured = env[@configuration_environment_key].to_s
@@ -250,6 +278,12 @@ module AgentCliRuntime
250
278
  flags.dup
251
279
  end
252
280
 
281
+ def capture_local(*arguments, env: ENV, timeout_sec: CAPTURE_TIMEOUT_SECONDS)
282
+ bounded_capture3(
283
+ bin(env:), *arguments, timeout_sec: timeout_sec, env: env
284
+ )
285
+ end
286
+
253
287
  private
254
288
 
255
289
  def immutable_string(value)
@@ -103,6 +103,37 @@ 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",
@@ -242,7 +273,45 @@ module AgentCliRuntime
242
273
  end
243
274
  )
244
275
 
245
- PROFILES = [ CLAUDE, CODEX, PI, GROK ].to_h do |profile|
276
+ OPENCODE = Profile.new(
277
+ name: :opencode,
278
+ bin_default: "opencode",
279
+ env_bin_override_keys: %w[
280
+ AGENT_CLI_RUNTIME_OPENCODE_BIN HIVE_OPENCODE_BIN
281
+ ],
282
+ headless_flag: "run",
283
+ output_format_flags: [ "--format", "json" ],
284
+ version_flag: "--version",
285
+ min_version: "1.18.16",
286
+ model_argument_builder: ->(model) { opencode_model_arguments(model) },
287
+ effort_argument_builder:
288
+ ->(variant) { opencode_variant_arguments(variant) },
289
+ launcher_identity: "opencode-cli/v1",
290
+ credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
291
+ configuration_environment_key: "OPENCODE_CONFIG_DIR",
292
+ default_configuration_directory: ".config/opencode",
293
+ permission_policy_required: true,
294
+ result_parser: OpenCode::ResultParser,
295
+ cli_capabilities: {
296
+ json_events: [ "run", "--format" ],
297
+ working_directory: [ "run", "--dir" ],
298
+ model_variant: [ "run", "--variant" ],
299
+ pure: [ "run", "--pure" ],
300
+ sanitized_export: [ "export", "--sanitize" ]
301
+ },
302
+ auth_configuration_probe: lambda do |home:, env:|
303
+ if env_configured?(env, *PI_CREDENTIAL_ENVIRONMENT_KEYS)
304
+ AuthConfiguration.new(status: :configured, source: "environment")
305
+ else
306
+ auth_from_file(
307
+ opencode_auth_path(home:, env:),
308
+ source: "opencode auth.json"
309
+ )
310
+ end
311
+ end
312
+ )
313
+
314
+ PROFILES = [ CLAUDE, CODEX, PI, GROK, OPENCODE ].to_h do |profile|
246
315
  [ profile.name, profile ]
247
316
  end.freeze
248
317
  PROVIDER_ORDER = PROFILES.keys.freeze
@@ -63,9 +63,13 @@ module AgentCliRuntime
63
63
  compilation_error!(profile, e)
64
64
  end
65
65
 
66
- def prepare!(profile)
66
+ def prepare!(profile, env: ENV)
67
+ if profile.is_a?(OpenCodePreparationRequest)
68
+ return OpenCode::Overlay.prepare!(profile, env:)
69
+ end
70
+
67
71
  resolved = Profiles.resolve(profile)
68
- result = Probe.call(resolved)
72
+ result = Probe.call(resolved, env:)
69
73
  return result if result.ready
70
74
 
71
75
  evidence = unsupported_evidence(
@@ -121,6 +125,16 @@ module AgentCliRuntime
121
125
  )
122
126
  end
123
127
 
128
+ def parse_run(profile, stdout:)
129
+ Profiles.resolve(profile).parse_run(stdout)
130
+ end
131
+
132
+ def normalize(profile, captured, requested_route:)
133
+ Profiles.resolve(profile).normalize_captured_result(
134
+ captured, requested_route:
135
+ )
136
+ end
137
+
124
138
  def supported_evidence(profile, capability, arguments = [])
125
139
  CapabilityEvidence.new(
126
140
  capability: capability,
@@ -1,4 +1,11 @@
1
1
  module AgentCliRuntime
2
+ OPENCODE_OVERLAY_ENVIRONMENT_KEYS = %w[
3
+ XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME TMPDIR
4
+ OPENCODE_CONFIG OPENCODE_DISABLE_PROJECT_CONFIG
5
+ OPENCODE_DISABLE_CLAUDE_CODE OPENCODE_DISABLE_MODELS_FETCH
6
+ OPENCODE_DISABLE_AUTOUPDATE OPENCODE_PURE
7
+ ].freeze
8
+
2
9
  module Immutable
3
10
  module_function
4
11
 
@@ -14,6 +21,28 @@ module AgentCliRuntime
14
21
  def symbols(values)
15
22
  Array(values).map(&:to_sym).uniq.freeze
16
23
  end
24
+
25
+ def hash(value)
26
+ unless value.is_a?(Hash)
27
+ raise ArgumentError, "value must be a Hash"
28
+ end
29
+
30
+ value.each_with_object({}) do |(key, item), result|
31
+ result[string(key)] = deep(item)
32
+ end.freeze
33
+ end
34
+
35
+ def deep(value)
36
+ case value
37
+ when Hash then hash(value)
38
+ when Array then value.map { |item| deep(item) }.freeze
39
+ when String then string(value)
40
+ when Symbol, Numeric, TrueClass, FalseClass, NilClass then value
41
+ else
42
+ raise ArgumentError,
43
+ "unsupported immutable value #{value.class}"
44
+ end
45
+ end
17
46
  end
18
47
  private_constant :Immutable
19
48
 
@@ -173,4 +202,407 @@ module AgentCliRuntime
173
202
  )
174
203
  end
175
204
  end
205
+
206
+
207
+ Route = Data.define(:provider, :model) do
208
+ ROUTE_PATTERN = /\A(?<provider>[a-zA-Z0-9][a-zA-Z0-9._-]*)\/(?<model>[^\s\/][^\s]*)\z/
209
+
210
+ def initialize(provider:, model:)
211
+ normalized_provider = Immutable.string(provider)
212
+ normalized_model = Immutable.string(model)
213
+ route = "#{normalized_provider}/#{normalized_model}"
214
+ unless ROUTE_PATTERN.match?(route) && !normalized_model.include?("\0")
215
+ raise ArgumentError,
216
+ "OpenCode route must be a full provider/model value"
217
+ end
218
+
219
+ super(provider: normalized_provider, model: normalized_model)
220
+ end
221
+
222
+ def self.parse(value)
223
+ match = ROUTE_PATTERN.match(value.to_s)
224
+ unless match
225
+ raise ArgumentError,
226
+ "OpenCode route must be a full provider/model value"
227
+ end
228
+
229
+ new(provider: match[:provider], model: match[:model])
230
+ end
231
+
232
+ def to_s
233
+ "#{provider}/#{model}"
234
+ end
235
+ end
236
+
237
+ OpenCodePermissionPolicy = Data.define(:rules) do
238
+ ACTIONS = %w[allow ask deny].freeze
239
+
240
+ def initialize(rules = nil, **keywords)
241
+ value = rules || keywords[:rules] || keywords
242
+ normalized = Immutable.hash(value || {})
243
+ raise ArgumentError, "OpenCode permission policy cannot be empty" if normalized.empty?
244
+
245
+ validate_actions!(normalized)
246
+ super(rules: normalized)
247
+ end
248
+
249
+ private
250
+
251
+ def validate_actions!(value)
252
+ value.each_value do |entry|
253
+ if entry.is_a?(Hash)
254
+ validate_actions!(entry)
255
+ elsif !ACTIONS.include?(entry)
256
+ raise ArgumentError,
257
+ "OpenCode permission action must be allow, ask, or deny"
258
+ end
259
+ end
260
+ end
261
+ end
262
+
263
+ OpenCodePreparationRequest = Data.define(
264
+ :request, :working_directory, :invocation_root,
265
+ :configuration_path, :configuration,
266
+ :credential_environment_keys, :credential_file,
267
+ :permission_policy, :additional_read_roots,
268
+ :additional_write_roots, :edit_patterns, :plugins, :pure
269
+ ) do
270
+ def initialize(request:, working_directory:, invocation_root:,
271
+ configuration_path: nil, configuration: nil,
272
+ credential_environment_keys: [], credential_file: nil,
273
+ permission_policy: nil, additional_read_roots: [],
274
+ additional_write_roots: [], edit_patterns: [], plugins: [], pure: true)
275
+ unless request.is_a?(Request)
276
+ raise ArgumentError, "request must be an AgentCliRuntime::Request"
277
+ end
278
+ if configuration_path && configuration
279
+ raise ArgumentError,
280
+ "choose configuration_path or configuration, not both"
281
+ end
282
+ if permission_policy &&
283
+ !permission_policy.is_a?(OpenCodePermissionPolicy)
284
+ raise ArgumentError,
285
+ "permission_policy must be an OpenCodePermissionPolicy"
286
+ end
287
+
288
+ keys = Immutable.strings(credential_environment_keys)
289
+ invalid = keys.find { |key| !key.match?(/\A[A-Z][A-Z0-9_]*\z/) }
290
+ raise ArgumentError, "invalid credential environment key" if invalid
291
+ raise ArgumentError, "credential environment keys must be unique" if keys.uniq.length != keys.length
292
+ reserved = keys & OPENCODE_OVERLAY_ENVIRONMENT_KEYS
293
+ unless reserved.empty?
294
+ raise ArgumentError,
295
+ "credential environment keys cannot override the OpenCode overlay: #{reserved.join(', ')}"
296
+ end
297
+
298
+ super(
299
+ request: request,
300
+ working_directory: Immutable.string(working_directory),
301
+ invocation_root: Immutable.string(invocation_root),
302
+ configuration_path:
303
+ configuration_path.nil? ? nil : Immutable.string(configuration_path),
304
+ configuration:
305
+ configuration.nil? ? nil : Immutable.hash(configuration),
306
+ credential_environment_keys: keys,
307
+ credential_file:
308
+ credential_file.nil? ? nil : Immutable.string(credential_file),
309
+ permission_policy: permission_policy,
310
+ additional_read_roots: Immutable.strings(additional_read_roots),
311
+ additional_write_roots: Immutable.strings(additional_write_roots),
312
+ edit_patterns: Immutable.strings(edit_patterns),
313
+ plugins: Immutable.strings(plugins),
314
+ pure: pure != false
315
+ )
316
+ end
317
+ end
318
+
319
+ ProbeRequest = Data.define(
320
+ :profile, :route, :variant, :environment,
321
+ :credential_environment_keys, :credential_file_staged
322
+ ) do
323
+ def initialize(profile:, route:, variant: nil, environment: {},
324
+ credential_environment_keys: [],
325
+ credential_file_staged: false)
326
+ parsed_route = route.is_a?(Route) ? route : Route.parse(route)
327
+ super(
328
+ profile: profile,
329
+ route: parsed_route,
330
+ variant: variant.nil? ? nil : Immutable.string(variant),
331
+ environment: Immutable.hash(environment),
332
+ credential_environment_keys:
333
+ Immutable.strings(credential_environment_keys),
334
+ credential_file_staged: credential_file_staged == true
335
+ )
336
+ end
337
+ end
338
+
339
+ RouteProbeResult = Data.define(
340
+ :provider, :ready, :installed, :executable, :version,
341
+ :minimum_version, :auth_configuration, :route,
342
+ :route_available, :available_variants,
343
+ :capability_evidence, :diagnostic
344
+ ) do
345
+ def initialize(provider:, ready:, installed:, executable:, version:,
346
+ minimum_version:, auth_configuration:, route:,
347
+ route_available:, available_variants:,
348
+ capability_evidence:, diagnostic: nil)
349
+ super(
350
+ provider: provider.to_sym,
351
+ ready: ready == true,
352
+ installed: installed == true,
353
+ executable: Immutable.string(executable),
354
+ version: version.nil? ? nil : Immutable.string(version),
355
+ minimum_version:
356
+ minimum_version.nil? ? nil : Immutable.string(minimum_version),
357
+ auth_configuration: auth_configuration,
358
+ route: route,
359
+ route_available: route_available == true,
360
+ available_variants: Immutable.strings(available_variants),
361
+ capability_evidence: Array(capability_evidence).freeze,
362
+ diagnostic:
363
+ diagnostic.nil? ? nil : Immutable.string(diagnostic)
364
+ )
365
+ end
366
+ end
367
+
368
+ class PreparedInvocation
369
+ attr_reader :invocation, :environment, :credential_environment_keys,
370
+ :invocation_root, :generated_paths, :configuration_path,
371
+ :requested_route, :configuration_source, :probe_result,
372
+ :executable
373
+
374
+ def initialize(invocation:, environment:, credential_environment_keys:,
375
+ invocation_root:, generated_paths:, configuration_path:,
376
+ requested_route:, configuration_source:, probe_result:,
377
+ cleanup:, executable: nil)
378
+ @invocation = invocation
379
+ @environment = Immutable.hash(environment)
380
+ @credential_environment_keys =
381
+ Immutable.strings(credential_environment_keys)
382
+ @invocation_root = Immutable.string(invocation_root)
383
+ @generated_paths = Immutable.strings(generated_paths)
384
+ @configuration_path = Immutable.string(configuration_path)
385
+ @requested_route = requested_route
386
+ @configuration_source =
387
+ configuration_source.nil? ? nil : Immutable.string(configuration_source)
388
+ @probe_result = probe_result
389
+ @executable = Immutable.string(executable || invocation.argv.fetch(0))
390
+ @cleanup = cleanup
391
+ freeze
392
+ end
393
+
394
+ def environment_for(env: ENV)
395
+ selected = credential_environment_keys.each_with_object({}) do |key, values|
396
+ value = env[key]
397
+ values[key] = value.to_s unless value.to_s.empty?
398
+ end
399
+ selected.merge(environment).freeze
400
+ end
401
+
402
+ def cleanup!
403
+ @cleanup.call
404
+ nil
405
+ end
406
+ end
407
+
408
+ TerminationEvidence = Data.define(
409
+ :exit_code, :timed_out, :cancelled, :signal
410
+ ) do
411
+ def initialize(exit_code:, timed_out: false, cancelled: false, signal: nil)
412
+ unless exit_code.nil? || exit_code.is_a?(Integer)
413
+ raise ArgumentError, "exit_code must be an Integer or nil"
414
+ end
415
+
416
+ super(
417
+ exit_code: exit_code,
418
+ timed_out: timed_out == true,
419
+ cancelled: cancelled == true,
420
+ signal: signal.nil? ? nil : Immutable.string(signal)
421
+ )
422
+ end
423
+
424
+ def success?
425
+ !timed_out && !cancelled && signal.nil? && exit_code == 0
426
+ end
427
+ end
428
+
429
+ CapturedResult = Data.define(
430
+ :stdout, :stderr, :termination, :inspection_output
431
+ ) do
432
+ def initialize(stdout:, stderr:, termination:, inspection_output: nil)
433
+ unless termination.is_a?(TerminationEvidence)
434
+ raise ArgumentError, "termination must be TerminationEvidence"
435
+ end
436
+
437
+ super(
438
+ stdout: Immutable.string(stdout),
439
+ stderr: Immutable.string(stderr),
440
+ termination: termination,
441
+ inspection_output:
442
+ inspection_output.nil? ? nil : Immutable.string(inspection_output)
443
+ )
444
+ end
445
+ end
446
+
447
+ NormalizedUsage = Data.define(
448
+ :input, :output, :cache_read, :cache_write, :reasoning, :cost
449
+ ) do
450
+ def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil,
451
+ reasoning: nil, cost: nil)
452
+ super(
453
+ input: number(input, :input, integer: true),
454
+ output: number(output, :output, integer: true),
455
+ cache_read: number(cache_read, :cache_read, integer: true),
456
+ cache_write: number(cache_write, :cache_write, integer: true),
457
+ reasoning: number(reasoning, :reasoning, integer: true),
458
+ cost: number(cost, :cost, integer: false)
459
+ )
460
+ end
461
+
462
+ def cached
463
+ return nil if cache_read.nil? || cache_write.nil?
464
+
465
+ cache_read + cache_write
466
+ end
467
+
468
+ private
469
+
470
+ def number(value, label, integer:)
471
+ return nil if value.nil?
472
+ valid = integer ? value.is_a?(Integer) : value.is_a?(Numeric)
473
+ valid &&= value.finite? if value.respond_to?(:finite?)
474
+ unless valid && value >= 0
475
+ raise ArgumentError, "#{label} must be a non-negative number or nil"
476
+ end
477
+
478
+ value
479
+ end
480
+ end
481
+
482
+ RouteIdentity = Data.define(:requested, :actual, :resolution_status) do
483
+ RESOLUTION_STATUSES = %i[unobserved matched resolved_differently].freeze
484
+
485
+ def initialize(requested:, actual: nil, resolution_status: nil)
486
+ requested_route = requested.is_a?(Route) ? requested : Route.parse(requested)
487
+ actual_route =
488
+ if actual.nil?
489
+ nil
490
+ elsif actual.is_a?(Route)
491
+ actual
492
+ else
493
+ Route.parse(actual)
494
+ end
495
+ status = resolution_status&.to_sym ||
496
+ (actual_route.nil? ? :unobserved :
497
+ (requested_route == actual_route ? :matched : :resolved_differently))
498
+ unless RESOLUTION_STATUSES.include?(status)
499
+ raise ArgumentError, "invalid route resolution status"
500
+ end
501
+
502
+ super(
503
+ requested: requested_route,
504
+ actual: actual_route,
505
+ resolution_status: status
506
+ )
507
+ end
508
+ end
509
+
510
+ ParsedRun = Data.define(
511
+ :session_id, :terminal_message_id, :terminal_reason,
512
+ :final_message, :final_message_truncated, :preliminary_usage, :unknown_events
513
+ ) do
514
+ def initialize(session_id:, terminal_message_id:, terminal_reason:,
515
+ final_message:, preliminary_usage:, unknown_events: [],
516
+ final_message_truncated: false)
517
+ unless preliminary_usage.is_a?(NormalizedUsage)
518
+ raise ArgumentError, "preliminary_usage must be NormalizedUsage"
519
+ end
520
+
521
+ super(
522
+ session_id: Immutable.string(session_id),
523
+ terminal_message_id: Immutable.string(terminal_message_id),
524
+ terminal_reason: Immutable.string(terminal_reason),
525
+ final_message: Immutable.string(final_message),
526
+ final_message_truncated: final_message_truncated == true,
527
+ preliminary_usage: preliminary_usage,
528
+ unknown_events: Immutable.strings(unknown_events)
529
+ )
530
+ end
531
+ end
532
+
533
+ InspectionCommand = Data.define(
534
+ :argv, :stdin_data, :environment, :credential_environment_keys,
535
+ :session_id, :message_id
536
+ ) do
537
+ def initialize(argv:, environment:, credential_environment_keys:,
538
+ session_id:, message_id:, stdin_data: nil)
539
+ super(
540
+ argv: Immutable.strings(argv),
541
+ stdin_data: stdin_data.nil? ? nil : Immutable.string(stdin_data),
542
+ environment: Immutable.hash(environment),
543
+ credential_environment_keys:
544
+ Immutable.strings(credential_environment_keys),
545
+ session_id: Immutable.string(session_id),
546
+ message_id: Immutable.string(message_id)
547
+ )
548
+ end
549
+
550
+ def environment_for(env: ENV)
551
+ selected = credential_environment_keys.each_with_object({}) do |key, values|
552
+ value = env[key]
553
+ values[key] = value.to_s unless value.to_s.empty?
554
+ end
555
+ selected.merge(environment).freeze
556
+ end
557
+ end
558
+
559
+ NormalizedOutcome = Data.define(
560
+ :provider, :launcher_identity, :kind, :termination,
561
+ :final_message, :final_message_truncated, :identity, :usage, :diagnostic,
562
+ :unknown_events, :session_id, :message_id
563
+ ) do
564
+ KINDS = %i[
565
+ completed authentication_failure configuration_failure cli_failure
566
+ malformed_output cancelled timed_out
567
+ ].freeze
568
+
569
+ def initialize(provider:, launcher_identity:, kind:, termination:,
570
+ final_message: nil, identity:, usage: nil, diagnostic: nil,
571
+ unknown_events: [], session_id: nil, message_id: nil,
572
+ final_message_truncated: false)
573
+ normalized_kind = kind.to_sym
574
+ unless KINDS.include?(normalized_kind)
575
+ raise ArgumentError, "invalid normalized outcome kind"
576
+ end
577
+ unless termination.is_a?(TerminationEvidence)
578
+ raise ArgumentError, "termination must be TerminationEvidence"
579
+ end
580
+ unless identity.is_a?(RouteIdentity)
581
+ raise ArgumentError, "identity must be RouteIdentity"
582
+ end
583
+ unless usage.nil? || usage.is_a?(NormalizedUsage)
584
+ raise ArgumentError, "usage must be NormalizedUsage or nil"
585
+ end
586
+
587
+ super(
588
+ provider: provider.to_sym,
589
+ launcher_identity: Immutable.string(launcher_identity),
590
+ kind: normalized_kind,
591
+ termination: termination,
592
+ final_message:
593
+ final_message.nil? ? nil : Immutable.string(final_message),
594
+ final_message_truncated: final_message_truncated == true,
595
+ identity: identity,
596
+ usage: usage,
597
+ diagnostic: diagnostic.nil? ? nil : Immutable.string(diagnostic),
598
+ unknown_events: Immutable.strings(unknown_events),
599
+ session_id: session_id.nil? ? nil : Immutable.string(session_id),
600
+ message_id: message_id.nil? ? nil : Immutable.string(message_id)
601
+ )
602
+ end
603
+
604
+ def completed?
605
+ kind == :completed
606
+ end
607
+ end
176
608
  end
@@ -1,3 +1,3 @@
1
1
  module AgentCliRuntime
2
- VERSION = "0.1.1".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end
@@ -8,10 +8,14 @@ end
8
8
 
9
9
  require "agent_cli_runtime/redactor"
10
10
  require "agent_cli_runtime/usage_extractors"
11
+ require "agent_cli_runtime/opencode/result_parser"
11
12
  require "agent_cli_runtime/profile"
12
13
  require "agent_cli_runtime/profiles"
13
14
  require "agent_cli_runtime/probe"
14
15
  require "agent_cli_runtime/runtime"
16
+ require "agent_cli_runtime/opencode/probe"
17
+ require "agent_cli_runtime/opencode/overlay"
18
+ require "agent_cli_runtime/opencode/inspection"
15
19
  require "agent_cli_runtime/cli"
16
20
 
17
21
  module AgentCliRuntime
@@ -21,8 +25,8 @@ module AgentCliRuntime
21
25
  Runtime.compile(request)
22
26
  end
23
27
 
24
- def prepare!(profile)
25
- Runtime.prepare!(profile)
28
+ def prepare!(profile, env: ENV)
29
+ Runtime.prepare!(profile, env:)
26
30
  end
27
31
 
28
32
  def require_capability!(profile, capability)
@@ -37,8 +41,24 @@ module AgentCliRuntime
37
41
  Runtime.observe(profile, result)
38
42
  end
39
43
 
44
+ def parse_run(profile, stdout:)
45
+ Runtime.parse_run(profile, stdout:)
46
+ end
47
+
48
+ def prepare_inspection(prepared, parsed_run)
49
+ OpenCode::Inspection.compile(prepared, parsed_run)
50
+ end
51
+
52
+ def normalize(profile, captured, requested_route:)
53
+ Runtime.normalize(profile, captured, requested_route:)
54
+ end
55
+
40
56
  def probe(profile, home: nil, env: ENV)
41
- Probe.call(profile, home: home, env: env)
57
+ if profile.is_a?(ProbeRequest)
58
+ OpenCode::Probe.call(profile, env: env)
59
+ else
60
+ Probe.call(profile, home: home, env: env)
61
+ end
42
62
  end
43
63
 
44
64
  def probe_all(home: nil, env: ENV)