agent-cli-runtime 0.2.0 → 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.
@@ -0,0 +1,144 @@
1
+ require "json"
2
+
3
+ module AgentCliRuntime
4
+ module OpenCode
5
+ # Compiles provider-neutral permission modes into OpenCode's
6
+ # per-run permission document. The document can be written into an
7
+ # isolated overlay or supplied through OPENCODE_PERMISSION while the CLI
8
+ # continues to use its native configuration and login.
9
+ module Permissions
10
+ module_function
11
+
12
+ def compile(permission_mode:, permission_policy: nil,
13
+ working_directory:, additional_read_roots: [],
14
+ additional_write_roots: [], edit_patterns: [],
15
+ bash_patterns: [], plugins: [], runtime_write_roots: [])
16
+ mode = permission_mode
17
+ if mode.nil?
18
+ return nil unless permission_policy
19
+ unless permission_policy.is_a?(OpenCodePermissionPolicy)
20
+ raise ArgumentError,
21
+ "permission_policy must be an OpenCodePermissionPolicy"
22
+ end
23
+
24
+ return deep_copy(permission_policy.rules)
25
+ end
26
+
27
+ unless %w[read-only workspace-write].include?(mode)
28
+ raise ConfigurationError, "unsupported OpenCode permission mode"
29
+ end
30
+
31
+ roots = {
32
+ working: File.expand_path(working_directory),
33
+ read: expanded_roots(additional_read_roots),
34
+ write: expanded_roots(additional_write_roots)
35
+ }
36
+ runtime_roots = expanded_roots(runtime_write_roots)
37
+ external = { "*" => "deny" }
38
+ [ *roots.fetch(:read), *roots.fetch(:write), *runtime_roots ].uniq.each do |root|
39
+ external[root] = "allow"
40
+ external["#{root}/**"] = "allow"
41
+ end
42
+ common = {
43
+ "*" => "deny",
44
+ "read" => {
45
+ "*" => "allow",
46
+ "*.env" => "deny",
47
+ "*.env.*" => "deny",
48
+ "*.env.example" => "allow"
49
+ },
50
+ "glob" => "allow",
51
+ "grep" => "allow",
52
+ "list" => "allow",
53
+ "lsp" => "allow",
54
+ "skill" => Array(plugins).empty? ? "deny" : "allow",
55
+ "external_directory" => external
56
+ }
57
+ if mode == "read-only"
58
+ return common.merge(
59
+ "edit" => "deny", "bash" => "deny", "task" => "deny",
60
+ "webfetch" => "deny", "websearch" => "deny",
61
+ "question" => "deny"
62
+ )
63
+ end
64
+
65
+ writable_roots = [
66
+ roots.fetch(:working), *roots.fetch(:write), *runtime_roots
67
+ ].uniq
68
+ edit = { "*" => "deny" }
69
+ allows = if Array(edit_patterns).empty?
70
+ writable_roots.flat_map do |root|
71
+ root_edit_patterns(root, working: roots.fetch(:working))
72
+ end
73
+ else
74
+ normalize_declared_edit_patterns(
75
+ edit_patterns, writable_roots, working: roots.fetch(:working)
76
+ )
77
+ end
78
+ allows.each { |pattern| edit[pattern] = "allow" }
79
+ (roots.fetch(:read) - roots.fetch(:write)).each do |root|
80
+ root_edit_patterns(root, working: roots.fetch(:working)).each do |pattern|
81
+ edit[pattern] = "deny"
82
+ end
83
+ end
84
+ bash = { "*" => "deny" }
85
+ Array(bash_patterns).each { |pattern| bash[pattern.to_s] = "allow" }
86
+ common.merge(
87
+ "edit" => edit,
88
+ "bash" => Array(bash_patterns).empty? ? "deny" : bash,
89
+ "task" => "deny",
90
+ "webfetch" => "deny", "websearch" => "deny",
91
+ "question" => "deny"
92
+ )
93
+ end
94
+
95
+ def expanded_roots(values)
96
+ Array(values).map { |value| File.expand_path(value) }.uniq.freeze
97
+ end
98
+ private_class_method :expanded_roots
99
+
100
+ def root_edit_patterns(root, working:)
101
+ if root == working
102
+ [ "**" ]
103
+ elsif root.start_with?(working + File::SEPARATOR)
104
+ relative = root.delete_prefix(working + File::SEPARATOR)
105
+ [ relative, "#{relative}/**" ]
106
+ else
107
+ [ root, "#{root}/**" ]
108
+ end
109
+ end
110
+ private_class_method :root_edit_patterns
111
+
112
+ def normalize_declared_edit_patterns(patterns, writable_roots, working:)
113
+ Array(patterns).map do |value|
114
+ pattern = value.to_s.sub(%r{\A//}, "/")
115
+ unless File.absolute_path?(pattern) && !pattern.include?("\0")
116
+ raise ConfigurationError,
117
+ "OpenCode edit patterns must be absolute path patterns"
118
+ end
119
+ literal_prefix = pattern.split(/[*?]/, 2).first.sub(%r{/+\z}, "")
120
+ unless writable_roots.any? do |root|
121
+ literal_prefix == root ||
122
+ literal_prefix.start_with?(root + File::SEPARATOR)
123
+ end
124
+ raise ConfigurationError,
125
+ "OpenCode edit pattern is outside the declared write roots"
126
+ end
127
+ if pattern == working
128
+ "**"
129
+ elsif pattern.start_with?(working + File::SEPARATOR)
130
+ pattern.delete_prefix(working + File::SEPARATOR)
131
+ else
132
+ pattern
133
+ end
134
+ end.uniq.freeze
135
+ end
136
+ private_class_method :normalize_declared_edit_patterns
137
+
138
+ def deep_copy(value)
139
+ JSON.parse(JSON.generate(value))
140
+ end
141
+ private_class_method :deep_copy
142
+ end
143
+ end
144
+ end
@@ -6,6 +6,7 @@ module AgentCliRuntime
6
6
  REQUIRED_RUN_FLAGS = %w[
7
7
  --model --variant --format --dir --pure --auto
8
8
  ].freeze
9
+ MODEL_INVENTORY_TIMEOUT_SECONDS = 30
9
10
  SAFE_ENVIRONMENT_KEYS = %w[
10
11
  HOME LANG LC_ALL LOGNAME PATH SHELL SSL_CERT_DIR SSL_CERT_FILE
11
12
  TMPDIR USER
@@ -19,11 +20,11 @@ module AgentCliRuntime
19
20
  call!(request, env:)
20
21
  rescue Error => e
21
22
  profile = Profiles.resolve(request.profile)
22
- executable = profile.bin(env:)
23
+ executable = request.executable || profile.bin(env:)
23
24
  RouteProbeResult.new(
24
25
  provider: profile.name,
25
26
  ready: false,
26
- installed: profile.binary_installed?(env:),
27
+ installed: profile.binary_installed?(env:, executable:),
27
28
  executable: executable,
28
29
  version: nil,
29
30
  minimum_version: profile.min_version,
@@ -54,20 +55,25 @@ module AgentCliRuntime
54
55
  raise ArgumentError,
55
56
  "route-aware ProbeRequest currently requires profile :opencode"
56
57
  end
57
- installed = profile.binary_installed?(env:)
58
+ installed = profile.binary_installed?(env:, executable: request.executable)
58
59
  unless installed
59
60
  raise BinaryUnavailable,
60
- "opencode binary not runnable: #{profile.bin(env:)}"
61
+ "opencode binary not runnable: " \
62
+ "#{request.executable || profile.bin(env:)}"
61
63
  end
62
64
 
63
65
  child_env = child_environment(profile, request, env:)
64
- version = profile.check_version!(env: child_env)
66
+ version = profile.check_version!(
67
+ env: child_env, executable: request.executable
68
+ )
65
69
  evidence = [
66
70
  evidence(profile, :installation),
67
71
  evidence(profile, :version, [ version ])
68
72
  ]
69
73
 
70
- run_help = capture!(profile, child_env, "run", "--help")
74
+ run_help = capture!(
75
+ profile, child_env, "run", "--help", executable: request.executable
76
+ )
71
77
  missing = REQUIRED_RUN_FLAGS.reject { |flag| advertised?(run_help, flag) }
72
78
  unless missing.empty?
73
79
  raise UnsupportedCapability,
@@ -77,14 +83,18 @@ module AgentCliRuntime
77
83
  evidence(profile, capability_for(flag), [ flag ])
78
84
  end)
79
85
 
80
- export_help = capture!(profile, child_env, "export", "--help")
86
+ export_help = capture!(
87
+ profile, child_env, "export", "--help", executable: request.executable
88
+ )
81
89
  unless advertised?(export_help, "--sanitize")
82
90
  raise UnsupportedCapability,
83
91
  "OpenCode export is missing required --sanitize capability"
84
92
  end
85
93
  evidence << evidence(profile, :sanitized_export, [ "--sanitize" ])
86
94
 
87
- auth_output = capture!(profile, child_env, "auth", "list")
95
+ auth_output = capture!(
96
+ profile, child_env, "auth", "list", executable: request.executable
97
+ )
88
98
  configured_key = request.credential_environment_keys.find do |key|
89
99
  !env[key].to_s.empty?
90
100
  end
@@ -106,19 +116,30 @@ module AgentCliRuntime
106
116
  )
107
117
  evidence << evidence(profile, :auth_configuration)
108
118
 
109
- models_output = capture!(
110
- profile, child_env, "models", request.route.provider, "--verbose"
111
- )
112
- variants = variants_for(models_output, request.route.to_s)
113
- if variants.nil?
119
+ configured_variants = request.configured_variants
120
+ inventory_variants = if configured_variants.nil?
121
+ models_output = capture!(
122
+ profile, child_env, "models", request.route.provider, "--verbose",
123
+ executable: request.executable,
124
+ timeout_sec: MODEL_INVENTORY_TIMEOUT_SECONDS
125
+ )
126
+ variants_for(models_output, request.route.to_s)
127
+ end
128
+ if configured_variants.nil? && inventory_variants.nil?
114
129
  raise RouteUnavailable,
115
130
  "requested OpenCode route is unavailable in the local model inventory"
116
131
  end
132
+ variants = [ *inventory_variants, *configured_variants ].uniq.sort.freeze
117
133
  if request.variant && !variants.include?(request.variant)
118
134
  raise RouteUnavailable,
119
135
  "requested OpenCode variant is unavailable for the exact route"
120
136
  end
121
137
  evidence << evidence(profile, :model_route, [ request.route.to_s ])
138
+ if inventory_variants.nil?
139
+ evidence << evidence(
140
+ profile, :configured_model_route, [ request.route.to_s ]
141
+ )
142
+ end
122
143
  evidence << evidence(profile, :model_variant, [ request.variant ]) if
123
144
  request.variant
124
145
 
@@ -126,7 +147,7 @@ module AgentCliRuntime
126
147
  provider: profile.name,
127
148
  ready: true,
128
149
  installed: true,
129
- executable: profile.bin(env: child_env),
150
+ executable: request.executable || profile.bin(env: child_env),
130
151
  version: version,
131
152
  minimum_version: profile.min_version,
132
153
  auth_configuration: auth,
@@ -165,8 +186,11 @@ module AgentCliRuntime
165
186
  end
166
187
  private_class_method :child_environment
167
188
 
168
- def capture!(profile, environment, *arguments)
169
- out, err, status = profile.capture_local(*arguments, env: environment)
189
+ def capture!(profile, environment, *arguments, executable: nil,
190
+ timeout_sec: nil)
191
+ options = { env: environment, executable: executable }
192
+ options[:timeout_sec] = timeout_sec if timeout_sec
193
+ out, err, status = profile.capture_local(*arguments, **options)
170
194
  unless status.success?
171
195
  diagnostic = normalized_output("#{err}\n#{out}")
172
196
  raise ConfigurationError,
@@ -4,7 +4,9 @@ module AgentCliRuntime
4
4
  module OpenCode
5
5
  module ResultParser
6
6
  MAX_RUN_BYTES = 4 * 1024 * 1024
7
- MAX_EXPORT_BYTES = 4 * 1024 * 1024
7
+ # Sanitized exports contain the complete session and every tool result,
8
+ # so their bounded limit must accommodate long implementation sessions.
9
+ MAX_EXPORT_BYTES = 64 * 1024 * 1024
8
10
  MAX_FINAL_MESSAGE_BYTES = 1024 * 1024
9
11
  MAX_EVENTS = 10_000
10
12
  MAX_UNKNOWN_EVENTS = 16
@@ -22,8 +24,11 @@ module AgentCliRuntime
22
24
  AUTH_PATTERN = /auth|credential|api[ _-]?key|unauthorized|forbidden/i
23
25
  CONFIGURATION_PATTERN =
24
26
  /\b(?:Config(?:uration)?Error|UnknownProvider|UnknownModel|ModelNotFound|RouteUnavailable|VariantUnavailable)\b|\b(?:invalid|unknown|unsupported) (?:configuration|provider|model|route|variant)\b|\b(?:provider|model|route|variant)(?: [^\n]+)? (?:not found|unavailable)\b/i
27
+ UPSTREAM_TIMEOUT_PATTERN =
28
+ /\b(?:upstream\s+)?(?:idle\s+)?timeout\b|\btimed out\b|error_type['"\s:=>]+timeout\b|\bcode['"\s:=>]+504\b/i
25
29
  private_constant :KNOWN_EVENT_TYPES, :EVENT_PART_TYPES,
26
- :AUTH_PATTERN, :CONFIGURATION_PATTERN
30
+ :AUTH_PATTERN, :CONFIGURATION_PATTERN,
31
+ :UPSTREAM_TIMEOUT_PATTERN
27
32
 
28
33
  module_function
29
34
 
@@ -88,8 +93,6 @@ module AgentCliRuntime
88
93
  message = texts.filter_map do |message_id, text|
89
94
  text if message_id == terminal.fetch(:message_id)
90
95
  end.join
91
- malformed!("OpenCode terminal assistant message is empty") if message.empty?
92
-
93
96
  final_message_truncated = message.bytesize > MAX_FINAL_MESSAGE_BYTES
94
97
  ParsedRun.new(
95
98
  session_id: session_id,
@@ -166,18 +169,21 @@ module AgentCliRuntime
166
169
  messages = export["messages"]
167
170
  malformed!("OpenCode sanitized export messages must be an array") unless
168
171
  messages.is_a?(Array)
169
- matches = messages.filter_map do |message|
172
+ assistant = nil
173
+ messages.each do |message|
170
174
  next unless message.is_a?(Hash) && message["info"].is_a?(Hash)
171
175
 
172
176
  record = message.fetch("info")
173
177
  next unless record["id"] == message_id
174
178
 
175
- record
179
+ if assistant
180
+ malformed!("OpenCode sanitized export must contain one terminal assistant record")
181
+ end
182
+ assistant = record
176
183
  end
177
- unless matches.one?
184
+ unless assistant
178
185
  malformed!("OpenCode sanitized export must contain one terminal assistant record")
179
186
  end
180
- assistant = matches.fetch(0)
181
187
  unless assistant["role"] == "assistant" &&
182
188
  assistant["sessionID"] == session_id
183
189
  malformed!("OpenCode sanitized export terminal record is not correlated")
@@ -208,7 +214,10 @@ module AgentCliRuntime
208
214
  cache_read: numeric(cache, "read", integer: true),
209
215
  cache_write: numeric(cache, "write", integer: true),
210
216
  reasoning: numeric(tokens, "reasoning", integer: true),
211
- cost: numeric(assistant, "cost", integer: false)
217
+ input_includes_cache_read: cache.key?("read") ? true : nil,
218
+ input_includes_cache_write: cache.key?("write") ? true : nil,
219
+ output_includes_reasoning: tokens.key?("reasoning") ? false : nil,
220
+ provider_reported_cost: numeric(assistant, "cost", integer: false)
212
221
  )
213
222
  }.freeze
214
223
  rescue JSON::ParserError => e
@@ -231,7 +240,10 @@ module AgentCliRuntime
231
240
  cache_read: required_numeric(cache, "read", integer: true),
232
241
  cache_write: required_numeric(cache, "write", integer: true),
233
242
  reasoning: required_numeric(tokens, "reasoning", integer: true),
234
- cost: required_numeric(part, "cost", integer: false)
243
+ input_includes_cache_read: true,
244
+ input_includes_cache_write: true,
245
+ output_includes_reasoning: false,
246
+ provider_reported_cost: required_numeric(part, "cost", integer: false)
235
247
  )
236
248
  }.freeze
237
249
  rescue ArgumentError => e
@@ -279,6 +291,8 @@ module AgentCliRuntime
279
291
  :authentication_failure
280
292
  elsif corpus.match?(CONFIGURATION_PATTERN)
281
293
  :configuration_failure
294
+ elsif corpus.match?(UPSTREAM_TIMEOUT_PATTERN)
295
+ :timed_out
282
296
  else
283
297
  :cli_failure
284
298
  end
@@ -4,10 +4,10 @@ require "rubygems/version"
4
4
 
5
5
  module AgentCliRuntime
6
6
  class Profile
7
- PROMPT_STYLES = %i[positional headless_flag_value stdin].freeze
7
+ PROMPT_STYLES = %i[positional headless_flag_value stdin piped_stdin].freeze
8
8
  WORKSPACE_WRITE_PERMISSION_MODE = "workspace-write".freeze
9
9
  READ_ONLY_PERMISSION_MODE = "read-only".freeze
10
- CAPTURE_TIMEOUT_SECONDS = 10
10
+ CAPTURE_TIMEOUT_SECONDS = 120
11
11
  CAPTURE_POLL_SECONDS = 0.01
12
12
  CAPTURE_TERM_GRACE_SECONDS = 0.2
13
13
  CAPTURE_REAP_GRACE_SECONDS = 0.2
@@ -28,7 +28,8 @@ module AgentCliRuntime
28
28
  :cli_capabilities, :declared_capability_support,
29
29
  :credential_environment_keys, :configuration_environment_key,
30
30
  :default_configuration_directory,
31
- :permission_policy_required, :result_parser
31
+ :permission_policy_required, :result_parser,
32
+ :version_check_timeout_sec
32
33
 
33
34
  def initialize(name:, bin_default:, headless_flag:, version_flag:,
34
35
  env_bin_override_keys: [], permission_skip_flag: nil,
@@ -37,11 +38,13 @@ module AgentCliRuntime
37
38
  output_format_flags: [], min_version: nil,
38
39
  prompt_style: :positional, model_argument_builder: nil,
39
40
  effort_argument_builder: nil, launcher_identity: nil,
40
- usage_extractor: nil, auth_configuration_probe: nil,
41
+ usage_extractor: nil, error_extractor: nil,
42
+ auth_configuration_probe: nil,
41
43
  cli_capabilities: {}, raw_cli_arguments_supported: false,
42
44
  credential_environment_keys: [], configuration_environment_key: nil,
43
45
  default_configuration_directory: nil,
44
- permission_policy_required: false, result_parser: nil)
46
+ permission_policy_required: false, result_parser: nil,
47
+ version_check_timeout_sec: CAPTURE_TIMEOUT_SECONDS)
45
48
  normalized_prompt_style = prompt_style.to_sym
46
49
  unless PROMPT_STYLES.include?(normalized_prompt_style)
47
50
  raise ArgumentError,
@@ -68,6 +71,7 @@ module AgentCliRuntime
68
71
  @launcher_identity =
69
72
  immutable_string(launcher_identity || "agent-cli-runtime/v1:#{@name}")
70
73
  @usage_extractor = usage_extractor || ->(_event) { nil }
74
+ @error_extractor = error_extractor || ErrorExtractors::DEFAULT
71
75
  @auth_configuration_probe = auth_configuration_probe
72
76
  @cli_capabilities = normalize_cli_capabilities(cli_capabilities)
73
77
  @raw_cli_arguments_supported = raw_cli_arguments_supported == true
@@ -82,6 +86,10 @@ module AgentCliRuntime
82
86
  )
83
87
  @permission_policy_required = permission_policy_required == true
84
88
  @result_parser = result_parser
89
+ @version_check_timeout_sec = Float(version_check_timeout_sec)
90
+ unless @version_check_timeout_sec.positive?
91
+ raise ArgumentError, "version_check_timeout_sec must be positive"
92
+ end
85
93
  @declared_capability_support = build_declared_capability_support
86
94
  freeze
87
95
  end
@@ -153,8 +161,8 @@ module AgentCliRuntime
153
161
  @raw_cli_arguments_supported
154
162
  end
155
163
 
156
- def binary_installed?(env: ENV)
157
- executable = bin(env:)
164
+ def binary_installed?(env: ENV, executable: nil)
165
+ executable ||= bin(env:)
158
166
  return File.file?(executable) && File.executable?(executable) if executable.include?(File::SEPARATOR)
159
167
 
160
168
  env.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
@@ -165,10 +173,10 @@ module AgentCliRuntime
165
173
  false
166
174
  end
167
175
 
168
- def check_version!(env: ENV)
169
- executable = bin(env:)
176
+ def check_version!(env: ENV, executable: nil)
177
+ executable ||= bin(env:)
170
178
  out, _err, status = bounded_capture3(
171
- executable, @version_flag, timeout_sec: CAPTURE_TIMEOUT_SECONDS, env: env
179
+ executable, @version_flag, timeout_sec: @version_check_timeout_sec, env: env
172
180
  )
173
181
  unless status.success?
174
182
  raise BinaryUnavailable,
@@ -197,7 +205,7 @@ module AgentCliRuntime
197
205
  "#{@name} binary not runnable: #{executable} (#{e.class.name.split('::').last})"
198
206
  rescue Timeout::Error
199
207
  raise BinaryUnavailable,
200
- "#{@name} version check timed out after #{CAPTURE_TIMEOUT_SECONDS}s: #{executable}"
208
+ "#{@name} version check timed out after #{@version_check_timeout_sec}s: #{executable}"
201
209
  end
202
210
 
203
211
  def auth_configuration(home: nil, env: ENV)
@@ -221,6 +229,12 @@ module AgentCliRuntime
221
229
  nil
222
230
  end
223
231
 
232
+ def extract_error_event(event)
233
+ @error_extractor.call(event)
234
+ rescue StandardError
235
+ nil
236
+ end
237
+
224
238
  def parse_run(stdout)
225
239
  unless @result_parser
226
240
  raise UnsupportedCapability,
@@ -278,9 +292,10 @@ module AgentCliRuntime
278
292
  flags.dup
279
293
  end
280
294
 
281
- def capture_local(*arguments, env: ENV, timeout_sec: CAPTURE_TIMEOUT_SECONDS)
295
+ def capture_local(*arguments, env: ENV, timeout_sec: CAPTURE_TIMEOUT_SECONDS,
296
+ executable: nil)
282
297
  bounded_capture3(
283
- bin(env:), *arguments, timeout_sec: timeout_sec, env: env
298
+ executable || bin(env:), *arguments, timeout_sec: timeout_sec, env: env
284
299
  )
285
300
  end
286
301
 
@@ -139,6 +139,8 @@ module AgentCliRuntime
139
139
  bin_default: "claude",
140
140
  env_bin_override_keys: %w[AGENT_CLI_RUNTIME_CLAUDE_BIN HIVE_CLAUDE_BIN],
141
141
  headless_flag: "-p",
142
+ # Print mode reads stdin; keep large review prompts out of exec arguments.
143
+ prompt_style: :piped_stdin,
142
144
  permission_skip_flag: "--dangerously-skip-permissions",
143
145
  add_dir_flag: "--add-dir",
144
146
  tool_scope_flags: {
@@ -156,6 +158,7 @@ module AgentCliRuntime
156
158
  effort_argument_builder: ->(effort) { [ "--effort", effort ] },
157
159
  launcher_identity: "claude-code/v1",
158
160
  usage_extractor: UsageExtractors::CLAUDE,
161
+ error_extractor: ErrorExtractors::CLAUDE,
159
162
  credential_environment_keys: %w[
160
163
  ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_API_KEY
161
164
  ],
@@ -220,12 +223,17 @@ module AgentCliRuntime
220
223
  bin_default: "pi",
221
224
  env_bin_override_keys: %w[AGENT_CLI_RUNTIME_PI_BIN HIVE_PI_BIN],
222
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,
223
230
  output_format_flags: [ "--mode", "json", "--no-session" ],
224
231
  version_flag: "--version",
225
232
  min_version: "0.70.2",
226
233
  model_argument_builder: ->(model) { [ "--model", model ] },
227
234
  launcher_identity: "pi-coding-agent/v1",
228
235
  usage_extractor: UsageExtractors::PI,
236
+ error_extractor: ErrorExtractors::PI,
229
237
  credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
230
238
  configuration_environment_key: "PI_CODING_AGENT_DIR",
231
239
  default_configuration_directory: ".pi/agent",
@@ -247,9 +255,18 @@ module AgentCliRuntime
247
255
  name: :grok,
248
256
  bin_default: "grok",
249
257
  env_bin_override_keys: %w[AGENT_CLI_RUNTIME_GROK_BIN HIVE_GROK_BIN],
250
- headless_flag: "-p",
251
- 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,
252
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" ],
253
270
  output_format_flags: [ "--output-format", "streaming-json" ],
254
271
  version_flag: "--version",
255
272
  min_version: "0.2.90",
@@ -282,6 +299,14 @@ module AgentCliRuntime
282
299
  headless_flag: "run",
283
300
  output_format_flags: [ "--format", "json" ],
284
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,
285
310
  min_version: "1.18.16",
286
311
  model_argument_builder: ->(model) { opencode_model_arguments(model) },
287
312
  effort_argument_builder:
@@ -291,6 +316,7 @@ module AgentCliRuntime
291
316
  configuration_environment_key: "OPENCODE_CONFIG_DIR",
292
317
  default_configuration_directory: ".config/opencode",
293
318
  permission_policy_required: true,
319
+ error_extractor: ErrorExtractors::OPENCODE,
294
320
  result_parser: OpenCode::ResultParser,
295
321
  cli_capabilities: {
296
322
  json_events: [ "run", "--format" ],