harnex 0.4.0 → 0.6.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 +4 -4
- data/CHANGELOG.md +39 -0
- data/TECHNICAL.md +19 -1
- data/lib/harnex/adapters/base.rb +14 -0
- data/lib/harnex/adapters/codex.rb +36 -0
- data/lib/harnex/adapters/codex_appserver.rb +390 -0
- data/lib/harnex/adapters.rb +16 -3
- data/lib/harnex/cli.rb +3 -0
- data/lib/harnex/commands/doctor.rb +75 -0
- data/lib/harnex/commands/run.rb +51 -7
- data/lib/harnex/commands/wait.rb +109 -3
- data/lib/harnex/core.rb +78 -2
- data/lib/harnex/runtime/session.rb +408 -4
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +1 -0
- metadata +5 -2
|
@@ -5,10 +5,41 @@ require "pty"
|
|
|
5
5
|
module Harnex
|
|
6
6
|
class Session
|
|
7
7
|
OUTPUT_BUFFER_LIMIT = 64 * 1024
|
|
8
|
+
TRANSCRIPT_TAIL_BYTES = 16 * 1024
|
|
9
|
+
USAGE_FIELDS = %i[
|
|
10
|
+
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens agent_session_id
|
|
11
|
+
].freeze
|
|
12
|
+
class EventCounters
|
|
13
|
+
def initialize
|
|
14
|
+
@counts = {
|
|
15
|
+
stalls: 0,
|
|
16
|
+
force_resumes: 0,
|
|
17
|
+
disconnections: 0,
|
|
18
|
+
compactions: 0
|
|
19
|
+
}
|
|
20
|
+
end
|
|
8
21
|
|
|
9
|
-
|
|
22
|
+
def record(type)
|
|
23
|
+
case type.to_s
|
|
24
|
+
when "log_idle"
|
|
25
|
+
@counts[:stalls] += 1
|
|
26
|
+
when "resume"
|
|
27
|
+
@counts[:force_resumes] += 1
|
|
28
|
+
when "disconnect", "disconnection", "disconnected"
|
|
29
|
+
@counts[:disconnections] += 1
|
|
30
|
+
when "compaction"
|
|
31
|
+
@counts[:compactions] += 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
10
34
|
|
|
11
|
-
|
|
35
|
+
def snapshot
|
|
36
|
+
@counts.dup
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path
|
|
41
|
+
|
|
42
|
+
def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL)
|
|
12
43
|
@adapter = adapter
|
|
13
44
|
@command = command
|
|
14
45
|
@repo_root = repo_root
|
|
@@ -17,6 +48,9 @@ module Harnex
|
|
|
17
48
|
@watch = watch
|
|
18
49
|
@description = description.to_s.strip
|
|
19
50
|
@description = nil if @description.empty?
|
|
51
|
+
@meta = meta
|
|
52
|
+
@summary_out = summary_out.to_s.strip
|
|
53
|
+
@summary_out = nil if @summary_out.empty?
|
|
20
54
|
@registry_path = Harnex.registry_path(repo_root, @id)
|
|
21
55
|
@output_log_path = Harnex.output_log_path(repo_root, @id)
|
|
22
56
|
@events_log_path = Harnex.events_log_path(repo_root, @id)
|
|
@@ -34,6 +68,13 @@ module Harnex
|
|
|
34
68
|
@output_log = nil
|
|
35
69
|
@events_log = nil
|
|
36
70
|
@events_log_seq = 0
|
|
71
|
+
@event_counters = EventCounters.new
|
|
72
|
+
@git_start = {}
|
|
73
|
+
@git_end = {}
|
|
74
|
+
@usage_summary = {}
|
|
75
|
+
@ended_at = nil
|
|
76
|
+
@exit_reason = nil
|
|
77
|
+
@last_completed_at = nil
|
|
37
78
|
@writer = nil
|
|
38
79
|
@pid = nil
|
|
39
80
|
@term_signal = nil
|
|
@@ -65,9 +106,17 @@ module Harnex
|
|
|
65
106
|
validate_binary! if validate_binary
|
|
66
107
|
prepare_output_log
|
|
67
108
|
prepare_events_log
|
|
109
|
+
|
|
110
|
+
return run_jsonrpc if adapter.transport == :stdio_jsonrpc
|
|
111
|
+
|
|
112
|
+
run_pty
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def run_pty
|
|
68
116
|
@reader, @writer, @pid = PTY.spawn(child_env, *command)
|
|
69
117
|
@writer.sync = true
|
|
70
|
-
|
|
118
|
+
emit_started_event
|
|
119
|
+
emit_git_start_event
|
|
71
120
|
|
|
72
121
|
install_signal_handlers
|
|
73
122
|
sync_window_size
|
|
@@ -84,9 +133,15 @@ module Harnex
|
|
|
84
133
|
_, status = Process.wait2(pid)
|
|
85
134
|
@term_signal = status.signaled? ? status.termsig : nil
|
|
86
135
|
@exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
|
|
87
|
-
|
|
136
|
+
@ended_at = Time.now
|
|
88
137
|
|
|
89
138
|
output_thread.join(1)
|
|
139
|
+
emit_session_end_telemetry
|
|
140
|
+
@exit_reason = classify_exit
|
|
141
|
+
summary_record = build_summary_record
|
|
142
|
+
append_summary_record(summary_record)
|
|
143
|
+
emit_summary_event
|
|
144
|
+
emit_exit_event
|
|
90
145
|
input_thread&.kill
|
|
91
146
|
watch_thread&.kill
|
|
92
147
|
@exit_code
|
|
@@ -132,6 +187,10 @@ module Harnex
|
|
|
132
187
|
payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
|
|
133
188
|
payload[:agent_state] = @state_machine.to_s
|
|
134
189
|
payload[:inbox] = @inbox.stats
|
|
190
|
+
payload[:last_completed_at] = @last_completed_at&.iso8601
|
|
191
|
+
payload[:model] = meta_hash["model"]
|
|
192
|
+
payload[:effort] = meta_hash["effort"]
|
|
193
|
+
payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
|
|
135
194
|
payload
|
|
136
195
|
end
|
|
137
196
|
|
|
@@ -146,6 +205,18 @@ module Harnex
|
|
|
146
205
|
end
|
|
147
206
|
|
|
148
207
|
def inject_stop
|
|
208
|
+
if adapter.transport == :stdio_jsonrpc
|
|
209
|
+
@inject_mutex.synchronize do
|
|
210
|
+
begin
|
|
211
|
+
adapter.interrupt
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
@state_machine.force_busy!
|
|
216
|
+
end
|
|
217
|
+
return { ok: true, signal: "interrupt_sent" }
|
|
218
|
+
end
|
|
219
|
+
|
|
149
220
|
raise "session is not running" unless pid && Harnex.alive_pid?(pid)
|
|
150
221
|
|
|
151
222
|
@inject_mutex.synchronize do
|
|
@@ -157,6 +228,10 @@ module Harnex
|
|
|
157
228
|
end
|
|
158
229
|
|
|
159
230
|
def inject_via_adapter(text:, submit:, enter_only:, force: false)
|
|
231
|
+
if adapter.transport == :stdio_jsonrpc
|
|
232
|
+
return inject_via_jsonrpc(text: text, force: force)
|
|
233
|
+
end
|
|
234
|
+
|
|
160
235
|
snapshot = adapter.wait_for_sendable(method(:screen_snapshot), submit: submit, enter_only: enter_only, force: force)
|
|
161
236
|
payload = adapter.build_send_payload(
|
|
162
237
|
text: text,
|
|
@@ -181,8 +256,32 @@ module Harnex
|
|
|
181
256
|
.tap { emit_send_event(text, force: payload[:force]) }
|
|
182
257
|
end
|
|
183
258
|
|
|
259
|
+
def inject_via_jsonrpc(text:, force: false)
|
|
260
|
+
turn_id = nil
|
|
261
|
+
@inject_mutex.synchronize do
|
|
262
|
+
turn_id = adapter.dispatch(prompt: text)
|
|
263
|
+
@state_machine.force_busy!
|
|
264
|
+
@injected_count += 1
|
|
265
|
+
@last_injected_at = Time.now
|
|
266
|
+
persist_registry
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
emit_send_event(text, force: force)
|
|
270
|
+
{
|
|
271
|
+
ok: true,
|
|
272
|
+
cli: adapter.key,
|
|
273
|
+
bytes_written: text.to_s.bytesize,
|
|
274
|
+
injected_count: @injected_count,
|
|
275
|
+
newline: false,
|
|
276
|
+
input_state: adapter.input_state(nil),
|
|
277
|
+
force: force,
|
|
278
|
+
turn_id: turn_id
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
|
|
184
282
|
def sync_window_size
|
|
185
283
|
return unless STDIN.tty?
|
|
284
|
+
return unless @writer
|
|
186
285
|
|
|
187
286
|
@writer.winsize = STDIN.winsize
|
|
188
287
|
rescue StandardError
|
|
@@ -195,6 +294,154 @@ module Harnex
|
|
|
195
294
|
|
|
196
295
|
private
|
|
197
296
|
|
|
297
|
+
def run_jsonrpc
|
|
298
|
+
adapter.on_notification { |msg| handle_rpc_notification(msg) }
|
|
299
|
+
adapter.on_disconnect { |err| handle_rpc_disconnect(err) }
|
|
300
|
+
|
|
301
|
+
adapter.start_rpc(env: child_env, cwd: repo_root)
|
|
302
|
+
@pid = adapter.pid
|
|
303
|
+
emit_started_event
|
|
304
|
+
emit_git_start_event
|
|
305
|
+
|
|
306
|
+
install_signal_handlers
|
|
307
|
+
@server = ApiServer.new(self)
|
|
308
|
+
@server.start
|
|
309
|
+
persist_registry
|
|
310
|
+
|
|
311
|
+
watch_thread = start_watch_thread
|
|
312
|
+
@inbox.start
|
|
313
|
+
|
|
314
|
+
if @pid
|
|
315
|
+
begin
|
|
316
|
+
_, status = Process.wait2(@pid)
|
|
317
|
+
@term_signal = status.signaled? ? status.termsig : nil
|
|
318
|
+
@exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
|
|
319
|
+
rescue Errno::ECHILD
|
|
320
|
+
@exit_code = 0
|
|
321
|
+
end
|
|
322
|
+
else
|
|
323
|
+
@rpc_done_lock = Mutex.new
|
|
324
|
+
@rpc_done_cond = ConditionVariable.new
|
|
325
|
+
@rpc_done_lock.synchronize { @rpc_done_cond.wait(@rpc_done_lock) until @rpc_done }
|
|
326
|
+
@exit_code = 0
|
|
327
|
+
end
|
|
328
|
+
@ended_at = Time.now
|
|
329
|
+
|
|
330
|
+
emit_session_end_telemetry
|
|
331
|
+
@exit_reason = classify_exit
|
|
332
|
+
summary_record = build_summary_record
|
|
333
|
+
append_summary_record(summary_record)
|
|
334
|
+
emit_summary_event
|
|
335
|
+
emit_exit_event
|
|
336
|
+
watch_thread&.kill
|
|
337
|
+
@exit_code
|
|
338
|
+
ensure
|
|
339
|
+
@inbox.stop
|
|
340
|
+
@server&.stop
|
|
341
|
+
begin
|
|
342
|
+
adapter.close
|
|
343
|
+
rescue StandardError
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
persist_exit_status
|
|
347
|
+
cleanup_registry
|
|
348
|
+
@output_log&.close unless @output_log&.closed?
|
|
349
|
+
@events_log&.close unless @events_log&.closed?
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def signal_rpc_done!
|
|
353
|
+
@rpc_done = true
|
|
354
|
+
if defined?(@rpc_done_lock) && @rpc_done_lock
|
|
355
|
+
@rpc_done_lock.synchronize { @rpc_done_cond&.signal }
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def handle_rpc_notification(message)
|
|
360
|
+
method = message["method"]
|
|
361
|
+
params = message["params"] || {}
|
|
362
|
+
|
|
363
|
+
case method
|
|
364
|
+
when "thread/started"
|
|
365
|
+
@rpc_thread_id = params["threadId"] || params["thread_id"]
|
|
366
|
+
when "turn/started"
|
|
367
|
+
emit_event("turn_started", turnId: params["turnId"] || params["turn_id"])
|
|
368
|
+
when "turn/completed"
|
|
369
|
+
@last_completed_at = Time.now
|
|
370
|
+
payload = { turnId: params["turnId"] || params["turn_id"] }
|
|
371
|
+
payload[:status] = params["status"] if params["status"]
|
|
372
|
+
payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"]
|
|
373
|
+
emit_event("task_complete", **payload)
|
|
374
|
+
when "item/completed"
|
|
375
|
+
emit_event("item_completed", item: params["item"])
|
|
376
|
+
text = render_item_text(params["item"])
|
|
377
|
+
record_synthesized(text) if text
|
|
378
|
+
when "thread/compacted"
|
|
379
|
+
emit_event("compaction", **params)
|
|
380
|
+
when "thread/tokenUsage/updated"
|
|
381
|
+
# Surfaced via status fields in Phase 4; no event spam.
|
|
382
|
+
@token_usage = params["usage"] || params
|
|
383
|
+
when "thread/status/changed"
|
|
384
|
+
# State machine reflects RPC state; no event needed.
|
|
385
|
+
nil
|
|
386
|
+
when "account/rateLimits/updated"
|
|
387
|
+
@rate_limits = params
|
|
388
|
+
when "error"
|
|
389
|
+
emit_event("disconnected", source: "error_notification", message: params["message"])
|
|
390
|
+
signal_rpc_done!
|
|
391
|
+
end
|
|
392
|
+
rescue StandardError => e
|
|
393
|
+
warn("harnex: rpc notification handler error: #{e.message}")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def handle_rpc_disconnect(error)
|
|
397
|
+
msg = error.is_a?(Hash) ? error["message"] : error&.message
|
|
398
|
+
emit_event("disconnected", source: "transport", message: msg) rescue nil
|
|
399
|
+
signal_rpc_done!
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def render_item_text(item)
|
|
403
|
+
return nil unless item.is_a?(Hash)
|
|
404
|
+
|
|
405
|
+
type = item["type"] || item["kind"]
|
|
406
|
+
case type
|
|
407
|
+
when "agent_message", "assistant_message"
|
|
408
|
+
item["text"] || item.dig("message", "text")
|
|
409
|
+
when "tool_call"
|
|
410
|
+
name = item["name"] || item.dig("tool", "name") || "tool"
|
|
411
|
+
params = item["params"] || item["arguments"]
|
|
412
|
+
"tool: #{name}#{params ? " #{summarize(params)}" : ""}"
|
|
413
|
+
else
|
|
414
|
+
item["text"]
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def summarize(value)
|
|
419
|
+
str = value.is_a?(String) ? value : JSON.generate(value)
|
|
420
|
+
str.length > 120 ? "#{str[0, 117]}..." : str
|
|
421
|
+
rescue StandardError
|
|
422
|
+
""
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def record_synthesized(text)
|
|
426
|
+
return if text.nil? || text.to_s.empty?
|
|
427
|
+
|
|
428
|
+
payload = text.to_s.dup
|
|
429
|
+
payload << "\n" unless payload.end_with?("\n")
|
|
430
|
+
bytes = payload.b
|
|
431
|
+
@mutex.synchronize do
|
|
432
|
+
append_output_log(bytes)
|
|
433
|
+
@output_buffer << bytes
|
|
434
|
+
overflow = @output_buffer.bytesize - OUTPUT_BUFFER_LIMIT
|
|
435
|
+
@output_buffer = @output_buffer.byteslice(overflow, OUTPUT_BUFFER_LIMIT) if overflow.positive?
|
|
436
|
+
end
|
|
437
|
+
begin
|
|
438
|
+
STDOUT.write(payload)
|
|
439
|
+
STDOUT.flush
|
|
440
|
+
rescue StandardError
|
|
441
|
+
nil
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
198
445
|
def child_env
|
|
199
446
|
env = {
|
|
200
447
|
"HARNEX_SESSION_ID" => session_id,
|
|
@@ -390,13 +637,170 @@ module Harnex
|
|
|
390
637
|
emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
|
|
391
638
|
end
|
|
392
639
|
|
|
640
|
+
def emit_started_event
|
|
641
|
+
payload = { pid: @pid }
|
|
642
|
+
payload[:meta] = meta if meta
|
|
643
|
+
emit_event("started", **payload)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def emit_git_start_event
|
|
647
|
+
@git_start = Harnex.git_capture_start(repo_root)
|
|
648
|
+
return if @git_start.empty?
|
|
649
|
+
|
|
650
|
+
emit_event("git", phase: "start", sha: @git_start[:sha], branch: @git_start[:branch])
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def emit_session_end_telemetry
|
|
654
|
+
@usage_summary = normalized_usage_summary(adapter.parse_session_summary(transcript_tail))
|
|
655
|
+
emit_event("usage", **@usage_summary)
|
|
656
|
+
|
|
657
|
+
@git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
|
|
658
|
+
return if @git_end.empty?
|
|
659
|
+
|
|
660
|
+
emit_event(
|
|
661
|
+
"git",
|
|
662
|
+
phase: "end",
|
|
663
|
+
sha: @git_end[:sha],
|
|
664
|
+
loc_added: @git_end[:loc_added],
|
|
665
|
+
loc_removed: @git_end[:loc_removed],
|
|
666
|
+
files_changed: @git_end[:files_changed],
|
|
667
|
+
commits: @git_end[:commits]
|
|
668
|
+
)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def emit_summary_event
|
|
672
|
+
emit_event("summary", path: summary_out, exit: @exit_reason)
|
|
673
|
+
end
|
|
674
|
+
|
|
393
675
|
def emit_exit_event
|
|
394
676
|
payload = { code: @exit_code }
|
|
395
677
|
payload[:signal] = @term_signal if @term_signal
|
|
678
|
+
payload[:reason] = @exit_reason if @exit_reason
|
|
396
679
|
emit_event("exited", **payload)
|
|
397
680
|
end
|
|
398
681
|
|
|
682
|
+
def classify_exit
|
|
683
|
+
return "timeout" if @exit_code == 124
|
|
684
|
+
return "failure" unless @exit_code == 0
|
|
685
|
+
return "success" if session_summary_present?
|
|
686
|
+
|
|
687
|
+
"disconnected"
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def session_summary_present?
|
|
691
|
+
@usage_summary.values.any? { |value| !value.nil? }
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def build_summary_record
|
|
695
|
+
{
|
|
696
|
+
meta: build_summary_meta,
|
|
697
|
+
predicted: summary_predicted_payload,
|
|
698
|
+
actual: build_summary_actual
|
|
699
|
+
}
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def build_summary_meta
|
|
703
|
+
info = Harnex.host_info
|
|
704
|
+
passthrough = meta_hash
|
|
705
|
+
|
|
706
|
+
{
|
|
707
|
+
id: id,
|
|
708
|
+
tmux_session: id,
|
|
709
|
+
description: description,
|
|
710
|
+
started_at: @started_at.iso8601,
|
|
711
|
+
ended_at: @ended_at&.iso8601,
|
|
712
|
+
harness: "harnex",
|
|
713
|
+
harness_version: Harnex.harness_version,
|
|
714
|
+
agent: adapter.key,
|
|
715
|
+
agent_version: nil,
|
|
716
|
+
agent_provider: nil,
|
|
717
|
+
agent_deployment: nil,
|
|
718
|
+
host: info[:host],
|
|
719
|
+
platform: info[:platform],
|
|
720
|
+
orchestrator: passthrough["orchestrator"],
|
|
721
|
+
orchestrator_session: passthrough["orchestrator_session"],
|
|
722
|
+
chain_id: passthrough["chain_id"],
|
|
723
|
+
parent_dispatch_id: passthrough["parent_dispatch_id"],
|
|
724
|
+
tier: passthrough["tier"],
|
|
725
|
+
phase: passthrough["phase"],
|
|
726
|
+
issue: passthrough["issue"],
|
|
727
|
+
plan: passthrough["plan"],
|
|
728
|
+
task_brief: passthrough["task_brief"],
|
|
729
|
+
repo: repo_root,
|
|
730
|
+
branch: @git_start[:branch],
|
|
731
|
+
start_sha: @git_start[:sha],
|
|
732
|
+
end_sha: @git_end[:sha]
|
|
733
|
+
}
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def build_summary_actual
|
|
737
|
+
counters = @event_counters.snapshot
|
|
738
|
+
counters[:disconnections] = [counters[:disconnections], 1].max if @exit_reason == "disconnected"
|
|
739
|
+
|
|
740
|
+
{
|
|
741
|
+
model: meta_hash["model"],
|
|
742
|
+
effort: meta_hash["effort"],
|
|
743
|
+
duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
|
|
744
|
+
input_tokens: @usage_summary[:input_tokens],
|
|
745
|
+
output_tokens: @usage_summary[:output_tokens],
|
|
746
|
+
reasoning_tokens: @usage_summary[:reasoning_tokens],
|
|
747
|
+
cached_tokens: @usage_summary[:cached_tokens],
|
|
748
|
+
cost_usd: nil,
|
|
749
|
+
loc_added: @git_end[:loc_added],
|
|
750
|
+
loc_removed: @git_end[:loc_removed],
|
|
751
|
+
files_changed: @git_end[:files_changed],
|
|
752
|
+
commits: @git_end[:commits],
|
|
753
|
+
exit: @exit_reason,
|
|
754
|
+
stalls: counters[:stalls],
|
|
755
|
+
force_resumes: counters[:force_resumes],
|
|
756
|
+
disconnections: counters[:disconnections],
|
|
757
|
+
compactions: counters[:compactions],
|
|
758
|
+
tests_run: nil,
|
|
759
|
+
tests_passed: nil,
|
|
760
|
+
tests_failed: nil
|
|
761
|
+
}
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def summary_predicted_payload
|
|
765
|
+
predicted = meta_hash["predicted"]
|
|
766
|
+
predicted.is_a?(Hash) ? predicted : {}
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def meta_hash
|
|
770
|
+
meta.is_a?(Hash) ? meta : {}
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def append_summary_record(record)
|
|
774
|
+
return unless summary_out
|
|
775
|
+
|
|
776
|
+
FileUtils.mkdir_p(File.dirname(summary_out))
|
|
777
|
+
File.open(summary_out, "ab") do |file|
|
|
778
|
+
file.write(JSON.generate(record))
|
|
779
|
+
file.write("\n")
|
|
780
|
+
end
|
|
781
|
+
rescue StandardError => e
|
|
782
|
+
warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
def normalized_usage_summary(summary)
|
|
786
|
+
summary ||= {}
|
|
787
|
+
USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def transcript_tail
|
|
791
|
+
return "" unless File.file?(output_log_path)
|
|
792
|
+
|
|
793
|
+
File.open(output_log_path, "rb") do |file|
|
|
794
|
+
size = file.size
|
|
795
|
+
file.seek([size - TRANSCRIPT_TAIL_BYTES, 0].max)
|
|
796
|
+
Harnex.strip_ansi(file.read.to_s)
|
|
797
|
+
end
|
|
798
|
+
rescue StandardError
|
|
799
|
+
""
|
|
800
|
+
end
|
|
801
|
+
|
|
399
802
|
def emit_event(type, **payload)
|
|
803
|
+
@event_counters.record(type)
|
|
400
804
|
@events_mutex.synchronize do
|
|
401
805
|
return unless @events_log
|
|
402
806
|
|
data/lib/harnex/version.rb
CHANGED
data/lib/harnex.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jikku Jose
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
|
|
14
14
|
adds a control plane for discovery, messaging, and coordination.
|
|
@@ -19,6 +19,7 @@ executables:
|
|
|
19
19
|
extensions: []
|
|
20
20
|
extra_rdoc_files: []
|
|
21
21
|
files:
|
|
22
|
+
- CHANGELOG.md
|
|
22
23
|
- GUIDE.md
|
|
23
24
|
- LICENSE
|
|
24
25
|
- README.md
|
|
@@ -30,8 +31,10 @@ files:
|
|
|
30
31
|
- lib/harnex/adapters/base.rb
|
|
31
32
|
- lib/harnex/adapters/claude.rb
|
|
32
33
|
- lib/harnex/adapters/codex.rb
|
|
34
|
+
- lib/harnex/adapters/codex_appserver.rb
|
|
33
35
|
- lib/harnex/adapters/generic.rb
|
|
34
36
|
- lib/harnex/cli.rb
|
|
37
|
+
- lib/harnex/commands/doctor.rb
|
|
35
38
|
- lib/harnex/commands/events.rb
|
|
36
39
|
- lib/harnex/commands/guide.rb
|
|
37
40
|
- lib/harnex/commands/logs.rb
|