harnex 0.3.4 → 0.5.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/GUIDE.md +11 -0
- data/README.md +37 -5
- data/TECHNICAL.md +72 -16
- data/lib/harnex/adapters/base.rb +4 -0
- data/lib/harnex/adapters/codex.rb +36 -0
- data/lib/harnex/cli.rb +7 -0
- data/lib/harnex/commands/events.rb +212 -0
- data/lib/harnex/commands/run.rb +165 -14
- data/lib/harnex/commands/status.rb +17 -3
- data/lib/harnex/commands/watch.rb +209 -0
- data/lib/harnex/commands/watch_presets.rb +17 -0
- data/lib/harnex/core.rb +109 -0
- data/lib/harnex/runtime/session.rb +280 -3
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- data/skills/harnex-buddy/SKILL.md +5 -0
- data/skills/harnex-dispatch/SKILL.md +16 -0
- metadata +5 -2
data/lib/harnex/core.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "fileutils"
|
|
3
|
+
require "optparse"
|
|
3
4
|
require "securerandom"
|
|
4
5
|
require "set"
|
|
5
6
|
require "socket"
|
|
@@ -37,6 +38,93 @@ module Harnex
|
|
|
37
38
|
File.expand_path(path)
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
def parse_duration_seconds(value, option_name:)
|
|
42
|
+
text = value.to_s.strip
|
|
43
|
+
raise OptionParser::InvalidArgument, "#{option_name} requires a value" if text.empty?
|
|
44
|
+
|
|
45
|
+
match = text.match(/\A([0-9]+(?:\.[0-9]+)?)([smhSMH]?)\z/)
|
|
46
|
+
unless match
|
|
47
|
+
raise OptionParser::InvalidArgument,
|
|
48
|
+
"#{option_name} must be a positive duration (examples: 30, 30s, 5m, 2h)"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
amount = Float(match[1])
|
|
52
|
+
multiplier =
|
|
53
|
+
case match[2].downcase
|
|
54
|
+
when "", "s" then 1.0
|
|
55
|
+
when "m" then 60.0
|
|
56
|
+
when "h" then 3600.0
|
|
57
|
+
else
|
|
58
|
+
raise OptionParser::InvalidArgument, "#{option_name} has an unsupported duration suffix"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
seconds = amount * multiplier
|
|
62
|
+
raise OptionParser::InvalidArgument, "#{option_name} must be greater than 0" if seconds <= 0.0
|
|
63
|
+
|
|
64
|
+
seconds
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def harness_version
|
|
68
|
+
VERSION
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def host_info
|
|
72
|
+
{
|
|
73
|
+
host: Socket.gethostname,
|
|
74
|
+
platform: RUBY_PLATFORM
|
|
75
|
+
}
|
|
76
|
+
rescue StandardError
|
|
77
|
+
{
|
|
78
|
+
host: nil,
|
|
79
|
+
platform: RUBY_PLATFORM
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def strip_ansi(text)
|
|
84
|
+
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def default_summary_out_path(repo_root)
|
|
88
|
+
koder_dir = File.join(repo_root.to_s, "koder")
|
|
89
|
+
return nil unless File.directory?(koder_dir)
|
|
90
|
+
|
|
91
|
+
File.join(koder_dir, "DISPATCH.jsonl")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def git_capture_start(repo_root)
|
|
95
|
+
sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
96
|
+
branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
97
|
+
return {} if sha.empty? || branch.empty?
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
sha: sha,
|
|
101
|
+
branch: branch
|
|
102
|
+
}
|
|
103
|
+
rescue StandardError
|
|
104
|
+
{}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def git_capture_end(repo_root, start_sha)
|
|
108
|
+
start_sha = start_sha.to_s.strip
|
|
109
|
+
return {} if start_sha.empty?
|
|
110
|
+
|
|
111
|
+
end_sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
112
|
+
range = "#{start_sha}..#{end_sha}"
|
|
113
|
+
shortstat = git_output(repo_root, "diff", "--shortstat", range)
|
|
114
|
+
commits = Integer(git_output(repo_root, "rev-list", "--count", range))
|
|
115
|
+
stats = parse_git_shortstat(shortstat)
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
sha: end_sha,
|
|
119
|
+
loc_added: stats.fetch(:loc_added),
|
|
120
|
+
loc_removed: stats.fetch(:loc_removed),
|
|
121
|
+
files_changed: stats.fetch(:files_changed),
|
|
122
|
+
commits: commits
|
|
123
|
+
}
|
|
124
|
+
rescue StandardError
|
|
125
|
+
{}
|
|
126
|
+
end
|
|
127
|
+
|
|
40
128
|
def repo_key(repo_root)
|
|
41
129
|
Digest::SHA256.hexdigest(repo_root)[0, 16]
|
|
42
130
|
end
|
|
@@ -113,6 +201,12 @@ module Harnex
|
|
|
113
201
|
File.join(output_dir, "#{session_file_slug(repo_root, id)}.log")
|
|
114
202
|
end
|
|
115
203
|
|
|
204
|
+
def events_log_path(repo_root, id)
|
|
205
|
+
events_dir = File.join(STATE_DIR, "events")
|
|
206
|
+
FileUtils.mkdir_p(events_dir)
|
|
207
|
+
File.join(events_dir, "#{session_file_slug(repo_root, id)}.jsonl")
|
|
208
|
+
end
|
|
209
|
+
|
|
116
210
|
def session_file_slug(repo_root, id)
|
|
117
211
|
slug = id_key(id)
|
|
118
212
|
slug = "default" if slug.empty?
|
|
@@ -283,4 +377,19 @@ module Harnex
|
|
|
283
377
|
debounce_seconds: WATCH_DEBOUNCE_SECONDS
|
|
284
378
|
)
|
|
285
379
|
end
|
|
380
|
+
|
|
381
|
+
def git_output(repo_root, *args)
|
|
382
|
+
stdout, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args)
|
|
383
|
+
raise "git #{args.join(' ')} failed" unless status.success?
|
|
384
|
+
|
|
385
|
+
stdout.strip
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def parse_git_shortstat(text)
|
|
389
|
+
{
|
|
390
|
+
files_changed: text.to_s[/(\d+)\s+files?\s+changed/, 1].to_i,
|
|
391
|
+
loc_added: text.to_s[/(\d+)\s+insertions?\(\+\)/, 1].to_i,
|
|
392
|
+
loc_removed: text.to_s[/(\d+)\s+deletions?\(-\)/, 1].to_i
|
|
393
|
+
}
|
|
394
|
+
end
|
|
286
395
|
end
|
|
@@ -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
|
|
21
|
+
|
|
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"
|
|
29
|
+
@counts[:disconnections] += 1
|
|
30
|
+
when "compaction"
|
|
31
|
+
@counts[:compactions] += 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def snapshot
|
|
36
|
+
@counts.dup
|
|
37
|
+
end
|
|
38
|
+
end
|
|
8
39
|
|
|
9
|
-
attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path
|
|
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
|
|
10
41
|
|
|
11
|
-
def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, inbox_ttl: Inbox::DEFAULT_TTL)
|
|
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,19 +48,32 @@ 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)
|
|
56
|
+
@events_log_path = Harnex.events_log_path(repo_root, @id)
|
|
22
57
|
@session_id = SecureRandom.hex(8)
|
|
23
58
|
@token = SecureRandom.hex(16)
|
|
24
59
|
@port = Harnex.allocate_port(repo_root, @id, port, host: host)
|
|
25
60
|
@mutex = Mutex.new
|
|
26
61
|
@inject_mutex = Mutex.new
|
|
62
|
+
@events_mutex = Mutex.new
|
|
27
63
|
@injected_count = 0
|
|
28
64
|
@last_injected_at = nil
|
|
29
65
|
@started_at = Time.now
|
|
30
66
|
@server = nil
|
|
31
67
|
@reader = nil
|
|
32
68
|
@output_log = nil
|
|
69
|
+
@events_log = nil
|
|
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
|
|
33
77
|
@writer = nil
|
|
34
78
|
@pid = nil
|
|
35
79
|
@term_signal = nil
|
|
@@ -60,8 +104,11 @@ module Harnex
|
|
|
60
104
|
def run(validate_binary: true)
|
|
61
105
|
validate_binary! if validate_binary
|
|
62
106
|
prepare_output_log
|
|
107
|
+
prepare_events_log
|
|
63
108
|
@reader, @writer, @pid = PTY.spawn(child_env, *command)
|
|
64
109
|
@writer.sync = true
|
|
110
|
+
emit_started_event
|
|
111
|
+
emit_git_start_event
|
|
65
112
|
|
|
66
113
|
install_signal_handlers
|
|
67
114
|
sync_window_size
|
|
@@ -78,8 +125,15 @@ module Harnex
|
|
|
78
125
|
_, status = Process.wait2(pid)
|
|
79
126
|
@term_signal = status.signaled? ? status.termsig : nil
|
|
80
127
|
@exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
|
|
128
|
+
@ended_at = Time.now
|
|
81
129
|
|
|
82
130
|
output_thread.join(1)
|
|
131
|
+
emit_session_end_telemetry
|
|
132
|
+
@exit_reason = classify_exit
|
|
133
|
+
summary_record = build_summary_record
|
|
134
|
+
append_summary_record(summary_record)
|
|
135
|
+
emit_summary_event
|
|
136
|
+
emit_exit_event
|
|
83
137
|
input_thread&.kill
|
|
84
138
|
watch_thread&.kill
|
|
85
139
|
@exit_code
|
|
@@ -91,6 +145,7 @@ module Harnex
|
|
|
91
145
|
cleanup_registry
|
|
92
146
|
@reader&.close unless @reader&.closed?
|
|
93
147
|
@output_log&.close unless @output_log&.closed?
|
|
148
|
+
@events_log&.close unless @events_log&.closed?
|
|
94
149
|
@writer&.close unless @writer&.closed?
|
|
95
150
|
end
|
|
96
151
|
|
|
@@ -109,8 +164,10 @@ module Harnex
|
|
|
109
164
|
started_at: @started_at.iso8601,
|
|
110
165
|
last_injected_at: @last_injected_at&.iso8601,
|
|
111
166
|
injected_count: @injected_count,
|
|
112
|
-
output_log_path: output_log_path
|
|
167
|
+
output_log_path: output_log_path,
|
|
168
|
+
events_log_path: events_log_path
|
|
113
169
|
}
|
|
170
|
+
payload.merge!(log_activity_snapshot)
|
|
114
171
|
payload[:description] = description if description
|
|
115
172
|
|
|
116
173
|
if watch
|
|
@@ -168,6 +225,7 @@ module Harnex
|
|
|
168
225
|
input_state: payload[:input_state],
|
|
169
226
|
force: payload[:force]
|
|
170
227
|
)
|
|
228
|
+
.tap { emit_send_event(text, force: payload[:force]) }
|
|
171
229
|
end
|
|
172
230
|
|
|
173
231
|
def sync_window_size
|
|
@@ -327,6 +385,14 @@ module Harnex
|
|
|
327
385
|
@output_log_failed = false
|
|
328
386
|
end
|
|
329
387
|
|
|
388
|
+
def prepare_events_log
|
|
389
|
+
@events_log&.close unless @events_log&.closed?
|
|
390
|
+
@events_log = File.open(events_log_path, "ab")
|
|
391
|
+
@events_log.sync = true
|
|
392
|
+
@events_log_failed = false
|
|
393
|
+
@events_log_seq = 0
|
|
394
|
+
end
|
|
395
|
+
|
|
330
396
|
def install_signal_handlers
|
|
331
397
|
%w[INT TERM HUP QUIT].each do |signal_name|
|
|
332
398
|
Signal.trap(signal_name) { forward_signal(signal_name) }
|
|
@@ -364,6 +430,217 @@ module Harnex
|
|
|
364
430
|
warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
|
|
365
431
|
end
|
|
366
432
|
|
|
433
|
+
def emit_send_event(text, force:)
|
|
434
|
+
compact = text.to_s
|
|
435
|
+
truncated = compact.length > 200
|
|
436
|
+
preview = truncated ? "#{compact[0, 200]}…" : compact
|
|
437
|
+
emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def emit_started_event
|
|
441
|
+
payload = { pid: @pid }
|
|
442
|
+
payload[:meta] = meta if meta
|
|
443
|
+
emit_event("started", **payload)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def emit_git_start_event
|
|
447
|
+
@git_start = Harnex.git_capture_start(repo_root)
|
|
448
|
+
return if @git_start.empty?
|
|
449
|
+
|
|
450
|
+
emit_event("git", phase: "start", sha: @git_start[:sha], branch: @git_start[:branch])
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def emit_session_end_telemetry
|
|
454
|
+
@usage_summary = normalized_usage_summary(adapter.parse_session_summary(transcript_tail))
|
|
455
|
+
emit_event("usage", **@usage_summary)
|
|
456
|
+
|
|
457
|
+
@git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
|
|
458
|
+
return if @git_end.empty?
|
|
459
|
+
|
|
460
|
+
emit_event(
|
|
461
|
+
"git",
|
|
462
|
+
phase: "end",
|
|
463
|
+
sha: @git_end[:sha],
|
|
464
|
+
loc_added: @git_end[:loc_added],
|
|
465
|
+
loc_removed: @git_end[:loc_removed],
|
|
466
|
+
files_changed: @git_end[:files_changed],
|
|
467
|
+
commits: @git_end[:commits]
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def emit_summary_event
|
|
472
|
+
emit_event("summary", path: summary_out, exit: @exit_reason)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def emit_exit_event
|
|
476
|
+
payload = { code: @exit_code }
|
|
477
|
+
payload[:signal] = @term_signal if @term_signal
|
|
478
|
+
payload[:reason] = @exit_reason if @exit_reason
|
|
479
|
+
emit_event("exited", **payload)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def classify_exit
|
|
483
|
+
return "timeout" if @exit_code == 124
|
|
484
|
+
return "failure" unless @exit_code == 0
|
|
485
|
+
return "success" if session_summary_present?
|
|
486
|
+
|
|
487
|
+
"disconnected"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def session_summary_present?
|
|
491
|
+
@usage_summary.values.any? { |value| !value.nil? }
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def build_summary_record
|
|
495
|
+
{
|
|
496
|
+
meta: build_summary_meta,
|
|
497
|
+
predicted: summary_predicted_payload,
|
|
498
|
+
actual: build_summary_actual
|
|
499
|
+
}
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def build_summary_meta
|
|
503
|
+
info = Harnex.host_info
|
|
504
|
+
passthrough = meta_hash
|
|
505
|
+
|
|
506
|
+
{
|
|
507
|
+
id: id,
|
|
508
|
+
tmux_session: id,
|
|
509
|
+
description: description,
|
|
510
|
+
started_at: @started_at.iso8601,
|
|
511
|
+
ended_at: @ended_at&.iso8601,
|
|
512
|
+
harness: "harnex",
|
|
513
|
+
harness_version: Harnex.harness_version,
|
|
514
|
+
agent: adapter.key,
|
|
515
|
+
agent_version: nil,
|
|
516
|
+
agent_provider: nil,
|
|
517
|
+
agent_deployment: nil,
|
|
518
|
+
host: info[:host],
|
|
519
|
+
platform: info[:platform],
|
|
520
|
+
orchestrator: passthrough["orchestrator"],
|
|
521
|
+
orchestrator_session: passthrough["orchestrator_session"],
|
|
522
|
+
chain_id: passthrough["chain_id"],
|
|
523
|
+
parent_dispatch_id: passthrough["parent_dispatch_id"],
|
|
524
|
+
tier: passthrough["tier"],
|
|
525
|
+
phase: passthrough["phase"],
|
|
526
|
+
issue: passthrough["issue"],
|
|
527
|
+
plan: passthrough["plan"],
|
|
528
|
+
task_brief: passthrough["task_brief"],
|
|
529
|
+
repo: repo_root,
|
|
530
|
+
branch: @git_start[:branch],
|
|
531
|
+
start_sha: @git_start[:sha],
|
|
532
|
+
end_sha: @git_end[:sha]
|
|
533
|
+
}
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def build_summary_actual
|
|
537
|
+
counters = @event_counters.snapshot
|
|
538
|
+
counters[:disconnections] = [counters[:disconnections], 1].max if @exit_reason == "disconnected"
|
|
539
|
+
|
|
540
|
+
{
|
|
541
|
+
model: meta_hash["model"],
|
|
542
|
+
effort: meta_hash["effort"],
|
|
543
|
+
duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
|
|
544
|
+
input_tokens: @usage_summary[:input_tokens],
|
|
545
|
+
output_tokens: @usage_summary[:output_tokens],
|
|
546
|
+
reasoning_tokens: @usage_summary[:reasoning_tokens],
|
|
547
|
+
cached_tokens: @usage_summary[:cached_tokens],
|
|
548
|
+
cost_usd: nil,
|
|
549
|
+
loc_added: @git_end[:loc_added],
|
|
550
|
+
loc_removed: @git_end[:loc_removed],
|
|
551
|
+
files_changed: @git_end[:files_changed],
|
|
552
|
+
commits: @git_end[:commits],
|
|
553
|
+
exit: @exit_reason,
|
|
554
|
+
stalls: counters[:stalls],
|
|
555
|
+
force_resumes: counters[:force_resumes],
|
|
556
|
+
disconnections: counters[:disconnections],
|
|
557
|
+
compactions: counters[:compactions],
|
|
558
|
+
tests_run: nil,
|
|
559
|
+
tests_passed: nil,
|
|
560
|
+
tests_failed: nil
|
|
561
|
+
}
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def summary_predicted_payload
|
|
565
|
+
predicted = meta_hash["predicted"]
|
|
566
|
+
predicted.is_a?(Hash) ? predicted : {}
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def meta_hash
|
|
570
|
+
meta.is_a?(Hash) ? meta : {}
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def append_summary_record(record)
|
|
574
|
+
return unless summary_out
|
|
575
|
+
|
|
576
|
+
FileUtils.mkdir_p(File.dirname(summary_out))
|
|
577
|
+
File.open(summary_out, "ab") do |file|
|
|
578
|
+
file.write(JSON.generate(record))
|
|
579
|
+
file.write("\n")
|
|
580
|
+
end
|
|
581
|
+
rescue StandardError => e
|
|
582
|
+
warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def normalized_usage_summary(summary)
|
|
586
|
+
summary ||= {}
|
|
587
|
+
USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def transcript_tail
|
|
591
|
+
return "" unless File.file?(output_log_path)
|
|
592
|
+
|
|
593
|
+
File.open(output_log_path, "rb") do |file|
|
|
594
|
+
size = file.size
|
|
595
|
+
file.seek([size - TRANSCRIPT_TAIL_BYTES, 0].max)
|
|
596
|
+
Harnex.strip_ansi(file.read.to_s)
|
|
597
|
+
end
|
|
598
|
+
rescue StandardError
|
|
599
|
+
""
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def emit_event(type, **payload)
|
|
603
|
+
@event_counters.record(type)
|
|
604
|
+
@events_mutex.synchronize do
|
|
605
|
+
return unless @events_log
|
|
606
|
+
|
|
607
|
+
@events_log_seq += 1
|
|
608
|
+
event = {
|
|
609
|
+
schema_version: 1,
|
|
610
|
+
seq: @events_log_seq,
|
|
611
|
+
ts: Time.now.utc.iso8601,
|
|
612
|
+
id: id,
|
|
613
|
+
type: type
|
|
614
|
+
}.merge(payload)
|
|
615
|
+
@events_log.write(JSON.generate(event))
|
|
616
|
+
@events_log.write("\n")
|
|
617
|
+
@events_log.flush
|
|
618
|
+
end
|
|
619
|
+
rescue StandardError => e
|
|
620
|
+
return if defined?(@events_log_failed) && @events_log_failed
|
|
621
|
+
|
|
622
|
+
@events_log_failed = true
|
|
623
|
+
warn("harnex: failed to write events log #{events_log_path}: #{e.message}")
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def log_activity_snapshot
|
|
627
|
+
return { log_mtime: nil, log_idle_s: nil } unless File.file?(output_log_path)
|
|
628
|
+
return { log_mtime: nil, log_idle_s: nil } if File.size?(output_log_path).nil?
|
|
629
|
+
|
|
630
|
+
mtime = File.mtime(output_log_path)
|
|
631
|
+
idle_seconds = (Time.now - mtime).to_i
|
|
632
|
+
idle_seconds = 0 if idle_seconds.negative?
|
|
633
|
+
{
|
|
634
|
+
log_mtime: mtime.iso8601,
|
|
635
|
+
log_idle_s: idle_seconds
|
|
636
|
+
}
|
|
637
|
+
rescue StandardError
|
|
638
|
+
{
|
|
639
|
+
log_mtime: nil,
|
|
640
|
+
log_idle_s: nil
|
|
641
|
+
}
|
|
642
|
+
end
|
|
643
|
+
|
|
367
644
|
def screen_snapshot
|
|
368
645
|
@mutex.synchronize { @output_buffer.dup }
|
|
369
646
|
end
|
data/lib/harnex/version.rb
CHANGED
data/lib/harnex.rb
CHANGED
|
@@ -12,12 +12,15 @@ require_relative "harnex/runtime/inbox"
|
|
|
12
12
|
require_relative "harnex/runtime/file_change_hook"
|
|
13
13
|
require_relative "harnex/runtime/api_server"
|
|
14
14
|
require_relative "harnex/runtime/session"
|
|
15
|
+
require_relative "harnex/commands/watch"
|
|
16
|
+
require_relative "harnex/commands/watch_presets"
|
|
15
17
|
require_relative "harnex/commands/run"
|
|
16
18
|
require_relative "harnex/commands/send"
|
|
17
19
|
require_relative "harnex/commands/wait"
|
|
18
20
|
require_relative "harnex/commands/stop"
|
|
19
21
|
require_relative "harnex/commands/status"
|
|
20
22
|
require_relative "harnex/commands/logs"
|
|
23
|
+
require_relative "harnex/commands/events"
|
|
21
24
|
require_relative "harnex/commands/pane"
|
|
22
25
|
require_relative "harnex/commands/recipes"
|
|
23
26
|
require_relative "harnex/commands/guide"
|
|
@@ -8,6 +8,11 @@ description: Spawn an accountability partner for long-running harnex sessions. U
|
|
|
8
8
|
For any long-running or unattended work, spawn a **buddy** — a second harnex
|
|
9
9
|
agent that watches the worker and nudges it if it stalls.
|
|
10
10
|
|
|
11
|
+
For plain stall recovery (force-resume on inactivity), prefer
|
|
12
|
+
`harnex run --watch --preset impl`. Use a buddy when you need reasoning that
|
|
13
|
+
policy checks cannot provide (doc drift, semantic checks, multi-session
|
|
14
|
+
correlation).
|
|
15
|
+
|
|
11
16
|
The buddy is an LLM, so it has intelligence for free. It reads the worker's
|
|
12
17
|
screen, reasons about whether it's stuck, and composes a meaningful nudge.
|
|
13
18
|
|
|
@@ -119,11 +119,27 @@ harnex run codex --id cx-impl-NN --tmux cx-impl-NN \
|
|
|
119
119
|
--context "Read and execute /tmp/task-impl-NN.md"
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
### Built-in monitoring (`--watch`)
|
|
123
|
+
|
|
124
|
+
For unattended implementation runs where you only need stall policy (not
|
|
125
|
+
Claude-side reasoning), bundle dispatch and monitoring in one command:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
harnex run codex --id cx-impl-42 --tmux cx-impl-42 --watch --preset impl
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`--preset impl` applies the standard 8m stall threshold with one forced resume.
|
|
132
|
+
Trade-off: `--watch` is foreground-blocking and policy-only (`stall-after` +
|
|
133
|
+
`max-resumes`). Use pane polling (and buddy when needed) for richer reasoning.
|
|
134
|
+
|
|
122
135
|
## 2. Watch
|
|
123
136
|
|
|
124
137
|
Poll the agent's screen with `harnex pane`. Checking is cheap — a 20-line
|
|
125
138
|
tail is a few hundred bytes.
|
|
126
139
|
|
|
140
|
+
For structured orchestration, prefer `harnex events --id <id>` over pane-text
|
|
141
|
+
scraping.
|
|
142
|
+
|
|
127
143
|
**Default: poll every 30 seconds.** This is fine for most work. The check
|
|
128
144
|
itself costs almost nothing and catches completion quickly.
|
|
129
145
|
|
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.5.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-01 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.
|
|
@@ -32,6 +32,7 @@ files:
|
|
|
32
32
|
- lib/harnex/adapters/codex.rb
|
|
33
33
|
- lib/harnex/adapters/generic.rb
|
|
34
34
|
- lib/harnex/cli.rb
|
|
35
|
+
- lib/harnex/commands/events.rb
|
|
35
36
|
- lib/harnex/commands/guide.rb
|
|
36
37
|
- lib/harnex/commands/logs.rb
|
|
37
38
|
- lib/harnex/commands/pane.rb
|
|
@@ -42,6 +43,8 @@ files:
|
|
|
42
43
|
- lib/harnex/commands/status.rb
|
|
43
44
|
- lib/harnex/commands/stop.rb
|
|
44
45
|
- lib/harnex/commands/wait.rb
|
|
46
|
+
- lib/harnex/commands/watch.rb
|
|
47
|
+
- lib/harnex/commands/watch_presets.rb
|
|
45
48
|
- lib/harnex/core.rb
|
|
46
49
|
- lib/harnex/runtime/api_server.rb
|
|
47
50
|
- lib/harnex/runtime/file_change_hook.rb
|