agent-harness 0.16.0 → 0.17.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: 12fc4554399bc5663e5fcde0747f0ba4f25b92499633fb9e6916f5f205f9f05e
4
- data.tar.gz: 2c5f3e1235a2c648c246988d684f9479a2151cbd6fb905e8774581115ce472ef
3
+ metadata.gz: f3de614caae7f2e65904c6118c209a2e149769e518e4a200c3efb77e8424db93
4
+ data.tar.gz: 8bd46e4f308b5de943554bd1dc3a4b31ab9180e2d1a2b61fd126840de6ff9029
5
5
  SHA512:
6
- metadata.gz: e0f815d6b6fb68e0d4b1307d0a4d594022cd814a213afbea4a87db1759c8a00513ec43b95a144698f5a3dca05e91e71474ee239865ad9b8174033c0694e7e838
7
- data.tar.gz: dfa7e99ee7c88cc4fce145dcbc431419d61a86dac4f9ebf5e3b6045c92729bb640b7dbbab5d1b597439039f0381d6f12f05de59731e20f282e8245c8dc15e052
6
+ metadata.gz: 4981d9c872f63e2739f7bda8f182387aac3218f7522e66cdc137aa1d34dfb51516b4e82e3193b8ee4b983c5a19cb68185d7bab5b630ace98a6b9bfad1b2ffb8d
7
+ data.tar.gz: d84d943eccbc10258eafbd60f53b6aad796cc2f2b9b663addb6c178cc29934ef3ee2d0c92925d627edb4c875c39a912915f0f7f04ed273be451a4a0a314bc047
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.16.0"
2
+ ".": "0.17.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.17.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.16.1...agent-harness/v0.17.0) (2026-05-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * **extensions:** add activity heartbeat support for OpenCode/KiloCode-compatible providers ([#201](https://github.com/viamin/agent-harness/issues/201)) ([4914f6d](https://github.com/viamin/agent-harness/commit/4914f6d7971c55600f268b6886b967b753b25c96))
9
+
10
+ ## [0.16.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.16.0...agent-harness/v0.16.1) (2026-05-03)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **kilocode:** config_file_content generates invalid JSON for kilo CLI v7.1.3 ([#190](https://github.com/viamin/agent-harness/issues/190)) ([0f7e24a](https://github.com/viamin/agent-harness/commit/0f7e24a8c342045d16a9332385e93f0607d0845e))
16
+
3
17
  ## [0.16.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.15.0...agent-harness/v0.16.0) (2026-05-03)
4
18
 
5
19
 
@@ -1187,6 +1187,39 @@ module AgentHarness
1187
1187
  nil
1188
1188
  end
1189
1189
 
1190
+ # Whether this provider supports activity heartbeat signaling.
1191
+ #
1192
+ # Providers that can emit a container-visible liveness signal during
1193
+ # long-running CLI execution return +true+. Downstream callers use
1194
+ # this to decide whether heartbeat-backed idle timeout is viable
1195
+ # without maintaining provider-name allowlists.
1196
+ #
1197
+ # @return [Boolean] true if the provider can emit heartbeat signals
1198
+ def supports_activity_heartbeat?
1199
+ false
1200
+ end
1201
+
1202
+ # Return structured heartbeat integration wiring for this provider.
1203
+ #
1204
+ # Providers that support activity heartbeat return a Hash describing
1205
+ # how to wire heartbeat file touches into the CLI execution. The
1206
+ # returned contract includes environment variables, execution
1207
+ # preparation (config/plugin file writes), and the heartbeat
1208
+ # granularity so downstream callers can enable heartbeat-backed idle
1209
+ # timeout without hardcoding provider-specific config logic.
1210
+ #
1211
+ # @param heartbeat_file_path [String] absolute path to the heartbeat
1212
+ # file that should be touched on activity
1213
+ # @return [Hash] heartbeat integration contract:
1214
+ # - +:supported+ [Boolean] whether heartbeat is available
1215
+ # - +:env+ [Hash] environment variables to set (empty when unsupported)
1216
+ # - +:preparation+ [ExecutionPreparation, nil] file writes for config/plugin wiring
1217
+ # - +:granularity+ [Symbol, nil] heartbeat event granularity
1218
+ # (+:tool_call+, +:turn+, or +:progress+)
1219
+ def heartbeat_integration(heartbeat_file_path:)
1220
+ {supported: false, env: {}, preparation: nil, granularity: nil}
1221
+ end
1222
+
1190
1223
  private
1191
1224
 
1192
1225
  def classify_smoke_test_message(message)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "shellwords"
4
5
 
5
6
  module AgentHarness
6
7
  module Providers
@@ -129,11 +130,50 @@ module AgentHarness
129
130
  }
130
131
  end
131
132
 
132
- def config_file_content(options = {})
133
+ def supports_activity_heartbeat?
134
+ true
135
+ end
136
+
137
+ def heartbeat_integration(heartbeat_file_path:)
138
+ unless heartbeat_file_path.is_a?(String) && !heartbeat_file_path.strip.empty?
139
+ raise ArgumentError, "heartbeat_file_path must be a non-empty String"
140
+ end
141
+ unless heartbeat_file_path.start_with?("/")
142
+ raise ArgumentError, "heartbeat_file_path must be an absolute path (got #{heartbeat_file_path.inspect})"
143
+ end
144
+
145
+ hook_script = heartbeat_hook_script(heartbeat_file_path)
146
+ config_payload = merge_heartbeat_hooks(hook_script)
147
+
148
+ preparation = ExecutionPreparation.new(
149
+ file_writes: [
150
+ {
151
+ path: heartbeat_hook_config_path,
152
+ content: JSON.pretty_generate(config_payload),
153
+ mode: 0o600
154
+ }
155
+ ]
156
+ )
157
+
133
158
  {
134
- provider: options[:api_provider],
135
- model: options[:model_id]
136
- }.to_json
159
+ supported: true,
160
+ env: {"KILO_HEARTBEAT_FILE" => heartbeat_file_path},
161
+ preparation: preparation,
162
+ granularity: :tool_call
163
+ }
164
+ end
165
+
166
+ def config_file_content(options = {})
167
+ # Only use explicit provider_name or default to "openai".
168
+ # api_provider is a generic backend label (e.g. "openrouter") that is not
169
+ # a valid Kilo built-in provider ID, so we must not fall back to it here.
170
+ provider_name = options[:provider_name] || "openai"
171
+ model_id = options[:model_id]
172
+
173
+ config = {provider: {provider_name => {}}}
174
+ config[:model] = "#{provider_name}/#{model_id}" if model_id
175
+
176
+ config.to_json
137
177
  end
138
178
 
139
179
  def error_patterns
@@ -292,6 +332,32 @@ module AgentHarness
292
332
 
293
333
  private
294
334
 
335
+ def heartbeat_hook_script(heartbeat_file_path)
336
+ "touch #{Shellwords.escape(heartbeat_file_path)}"
337
+ end
338
+
339
+ def heartbeat_hook_config_path
340
+ "~/.config/kilocode/hooks.json"
341
+ end
342
+
343
+ def merge_heartbeat_hooks(hook_script)
344
+ existing = load_existing_hooks_config(heartbeat_hook_config_path)
345
+ hooks = existing.fetch("hooks", {})
346
+ on_activity = hooks.fetch("on_activity", [])
347
+ on_activity = on_activity.dup
348
+ on_activity << {"command" => hook_script}
349
+ existing.merge("hooks" => hooks.merge("on_activity" => on_activity))
350
+ end
351
+
352
+ def load_existing_hooks_config(path)
353
+ expanded = File.expand_path(path)
354
+ return {} unless File.exist?(expanded)
355
+
356
+ JSON.parse(File.read(expanded))
357
+ rescue JSON::ParserError
358
+ {}
359
+ end
360
+
295
361
  def each_json_event(output)
296
362
  return if output.nil? || output.empty?
297
363
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "shellwords"
4
5
 
5
6
  module AgentHarness
6
7
  module Providers
@@ -147,6 +148,39 @@ module AgentHarness
147
148
  }
148
149
  end
149
150
 
151
+ def supports_activity_heartbeat?
152
+ true
153
+ end
154
+
155
+ def heartbeat_integration(heartbeat_file_path:)
156
+ unless heartbeat_file_path.is_a?(String) && !heartbeat_file_path.strip.empty?
157
+ raise ArgumentError, "heartbeat_file_path must be a non-empty String"
158
+ end
159
+ unless heartbeat_file_path.start_with?("/")
160
+ raise ArgumentError, "heartbeat_file_path must be an absolute path (got #{heartbeat_file_path.inspect})"
161
+ end
162
+
163
+ hook_script = heartbeat_hook_script(heartbeat_file_path)
164
+ config_payload = merge_heartbeat_hooks(hook_script)
165
+
166
+ preparation = ExecutionPreparation.new(
167
+ file_writes: [
168
+ {
169
+ path: heartbeat_hook_config_path,
170
+ content: serialize_opencode_config(config_payload),
171
+ mode: 0o600
172
+ }
173
+ ]
174
+ )
175
+
176
+ {
177
+ supported: true,
178
+ env: {"OPENCODE_HEARTBEAT_FILE" => heartbeat_file_path},
179
+ preparation: preparation,
180
+ granularity: :tool_call
181
+ }
182
+ end
183
+
150
184
  def error_patterns
151
185
  COMMON_ERROR_PATTERNS
152
186
  end
@@ -233,6 +267,32 @@ module AgentHarness
233
267
  JSON.pretty_generate(payload)
234
268
  end
235
269
 
270
+ def heartbeat_hook_script(heartbeat_file_path)
271
+ "touch #{Shellwords.escape(heartbeat_file_path)}"
272
+ end
273
+
274
+ def heartbeat_hook_config_path
275
+ "~/.config/opencode/hooks.json"
276
+ end
277
+
278
+ def merge_heartbeat_hooks(hook_script)
279
+ existing = load_existing_hooks_config(heartbeat_hook_config_path)
280
+ hooks = existing.fetch("hooks", {})
281
+ on_activity = hooks.fetch("on_activity", [])
282
+ on_activity = on_activity.dup
283
+ on_activity << {"command" => hook_script}
284
+ existing.merge("hooks" => hooks.merge("on_activity" => on_activity))
285
+ end
286
+
287
+ def load_existing_hooks_config(path)
288
+ expanded = File.expand_path(path)
289
+ return {} unless File.exist?(expanded)
290
+
291
+ JSON.parse(File.read(expanded))
292
+ rescue JSON::ParserError
293
+ {}
294
+ end
295
+
236
296
  def stringify_keys(hash)
237
297
  hash.each_with_object({}) do |(key, value), result|
238
298
  result[key.to_s] = value
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.16.0"
4
+ VERSION = "0.17.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan