harnex 0.6.5 → 0.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/README.md +102 -33
- data/TECHNICAL.md +23 -0
- data/guides/01_dispatch.md +24 -21
- data/guides/02_chain.md +6 -3
- data/guides/03_buddy.md +12 -11
- data/guides/04_monitoring.md +17 -16
- data/guides/05_naming.md +16 -15
- data/lib/harnex/adapters/base.rb +36 -2
- data/lib/harnex/adapters/claude.rb +4 -0
- data/lib/harnex/adapters/codex.rb +4 -0
- data/lib/harnex/adapters/codex_appserver.rb +56 -230
- data/lib/harnex/adapters/opencode.rb +132 -0
- data/lib/harnex/adapters/pi.rb +512 -0
- data/lib/harnex/adapters.rb +5 -1
- data/lib/harnex/cli.rb +9 -2
- data/lib/harnex/codex/app_server/client.rb +348 -0
- data/lib/harnex/commands/doctor.rb +95 -2
- data/lib/harnex/commands/history.rb +149 -0
- data/lib/harnex/commands/run.rb +47 -9
- data/lib/harnex/commands/wait.rb +77 -36
- data/lib/harnex/core.rb +3 -3
- data/lib/harnex/dispatch_history.rb +112 -0
- data/lib/harnex/runtime/session.rb +326 -44
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -0
- metadata +9 -4
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
module Adapters
|
|
3
|
+
class Opencode < Base
|
|
4
|
+
SUBMIT_DELAY_MS = 75
|
|
5
|
+
SUBMIT_DELAY_PER_KB_MS = 50
|
|
6
|
+
EXIT_SIGNAL_DELAY_MS = 100
|
|
7
|
+
|
|
8
|
+
def initialize(extra_args = [])
|
|
9
|
+
super("opencode", extra_args)
|
|
10
|
+
@screen_seen = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def provider
|
|
14
|
+
"opencode"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def base_command
|
|
18
|
+
["opencode"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def infer_repo_path(argv)
|
|
22
|
+
index = 0
|
|
23
|
+
while index < argv.length
|
|
24
|
+
arg = argv[index].to_s
|
|
25
|
+
case arg
|
|
26
|
+
when "--dir"
|
|
27
|
+
next_value = argv[index + 1]
|
|
28
|
+
return next_value if next_value && !next_value.to_s.strip.empty?
|
|
29
|
+
break
|
|
30
|
+
when /\A--dir=(.+)\z/
|
|
31
|
+
return Regexp.last_match(1)
|
|
32
|
+
end
|
|
33
|
+
index += 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
positional = argv.find { |value| value && !value.start_with?("-") }
|
|
37
|
+
return positional if positional
|
|
38
|
+
|
|
39
|
+
Dir.pwd
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def input_state(screen_text)
|
|
43
|
+
@screen_seen ||= !screen_text.to_s.empty?
|
|
44
|
+
lines = recent_lines(screen_text, limit: 80)
|
|
45
|
+
return prompt_state if @screen_seen && lines.empty?
|
|
46
|
+
return prompt_state if lines.any? { |line| prompt_line?(line) }
|
|
47
|
+
|
|
48
|
+
compact = lines.join(" ").gsub(/\s+/, " ")
|
|
49
|
+
|
|
50
|
+
# OpenCode's TUI keeps rendering on an alternate screen and does
|
|
51
|
+
# not expose a stable prompt token in plain text snapshots. Treat
|
|
52
|
+
# observed screen content as send-ready so inbox messages don't
|
|
53
|
+
# stall indefinitely in :unknown.
|
|
54
|
+
return prompt_state if compact.match?(/\bOpenCode\b/i)
|
|
55
|
+
return prompt_state if compact.match?(/\bSession\b.*\bContinue\b.*\bopencode\s+-s\b/i)
|
|
56
|
+
return prompt_state if compact.match?(/[■⬝╹]/)
|
|
57
|
+
|
|
58
|
+
return prompt_state if @screen_seen
|
|
59
|
+
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_session_summary(transcript_tail)
|
|
64
|
+
summary = {
|
|
65
|
+
input_tokens: nil,
|
|
66
|
+
output_tokens: nil,
|
|
67
|
+
reasoning_tokens: nil,
|
|
68
|
+
cached_tokens: nil,
|
|
69
|
+
total_tokens: nil,
|
|
70
|
+
agent_session_id: nil
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
text = normalized_screen_text(transcript_tail)
|
|
74
|
+
if (match = text.match(/\bContinue\s+opencode\s+-s\s+([A-Za-z0-9._:-]+)/))
|
|
75
|
+
summary[:agent_session_id] = match[1]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
summary
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
|
|
82
|
+
state = input_state(screen_text)
|
|
83
|
+
if !force && blocked_state?(state, enter_only: enter_only)
|
|
84
|
+
raise ArgumentError, blocked_message(state, enter_only: enter_only)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
steps = []
|
|
88
|
+
unless enter_only
|
|
89
|
+
body = text.to_s
|
|
90
|
+
steps << { text: body, newline: false } unless body.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if submit || enter_only
|
|
94
|
+
step = { text: submit_bytes, newline: false }
|
|
95
|
+
step[:delay_ms] = submit_delay_ms(text) if steps.any?
|
|
96
|
+
steps << step
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
steps: steps,
|
|
101
|
+
input_state: state,
|
|
102
|
+
force: force
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def inject_exit(writer)
|
|
107
|
+
# Ctrl+C is OpenCode's native terminal stop path. A second signal
|
|
108
|
+
# shortly after the first handles "interrupt-first, quit-second"
|
|
109
|
+
# cases when work is in-flight.
|
|
110
|
+
writer.write("\u0003")
|
|
111
|
+
writer.flush
|
|
112
|
+
sleep(EXIT_SIGNAL_DELAY_MS / 1000.0)
|
|
113
|
+
writer.write("\u0003")
|
|
114
|
+
writer.flush
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def prompt_state
|
|
120
|
+
{
|
|
121
|
+
state: "prompt",
|
|
122
|
+
input_ready: true
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def submit_delay_ms(text)
|
|
127
|
+
extra = (text.to_s.bytesize / 1024.0 * SUBMIT_DELAY_PER_KB_MS).ceil
|
|
128
|
+
SUBMIT_DELAY_MS + extra
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
module Harnex
|
|
5
|
+
module Adapters
|
|
6
|
+
# Pi RPC adapter — JSONL command/event protocol over stdio.
|
|
7
|
+
#
|
|
8
|
+
# Protocol docs: `pi --mode rpc` (strict LF-delimited JSON lines).
|
|
9
|
+
class Pi < Base
|
|
10
|
+
STOP_TERM_GRACE_SECONDS = 0.5
|
|
11
|
+
STOP_KILL_GRACE_SECONDS = 1.0
|
|
12
|
+
DIALOG_UI_METHODS = %w[select confirm input editor].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :initial_prompt, :last_completed_at
|
|
15
|
+
|
|
16
|
+
def initialize(extra_args = [])
|
|
17
|
+
super("pi", extra_args)
|
|
18
|
+
@initial_prompt = extract_initial_prompt(extra_args)
|
|
19
|
+
@notification_handler = nil
|
|
20
|
+
@disconnect_handler = nil
|
|
21
|
+
@read_io = nil
|
|
22
|
+
@write_io = nil
|
|
23
|
+
@pid = nil
|
|
24
|
+
@reader_thread = nil
|
|
25
|
+
@closed = false
|
|
26
|
+
@disconnect_signaled = false
|
|
27
|
+
@state = :disconnected
|
|
28
|
+
@next_id = 1
|
|
29
|
+
@pending = {}
|
|
30
|
+
@id_mutex = Mutex.new
|
|
31
|
+
@write_mutex = Mutex.new
|
|
32
|
+
@summary_mutex = Mutex.new
|
|
33
|
+
@session_summary = {}
|
|
34
|
+
@model = nil
|
|
35
|
+
@provider = nil
|
|
36
|
+
@session_stats_requested = false
|
|
37
|
+
@last_completed_at = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def transport
|
|
41
|
+
:stdio_jsonl_rpc
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def provider
|
|
45
|
+
@provider
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def current_model
|
|
49
|
+
@model
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def base_command
|
|
53
|
+
["pi", "--mode", "rpc"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_command
|
|
57
|
+
base_command + cli_extra_args
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def describe
|
|
61
|
+
{
|
|
62
|
+
transport: transport,
|
|
63
|
+
protocol: "jsonl",
|
|
64
|
+
events: %w[
|
|
65
|
+
agent_start agent_end turn_start turn_end message_start message_update message_end
|
|
66
|
+
tool_execution_start tool_execution_update tool_execution_end queue_update
|
|
67
|
+
compaction_start compaction_end auto_retry_start auto_retry_end extension_error
|
|
68
|
+
extension_ui_request
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def state
|
|
74
|
+
@state
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def input_state(_screen_text = nil)
|
|
78
|
+
{
|
|
79
|
+
state: @state.to_s,
|
|
80
|
+
input_ready: @state == :prompt
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
|
|
85
|
+
state = input_state(nil)
|
|
86
|
+
if !force && submit && !enter_only && state[:input_ready] != true
|
|
87
|
+
raise ArgumentError, blocked_message(state, enter_only: enter_only)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise ArgumentError, "Pi RPC cannot stage input without submitting it" unless submit || enter_only
|
|
91
|
+
raise ArgumentError, "Pi RPC does not support submit-only input" if enter_only
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
dispatch: { prompt: text.to_s },
|
|
95
|
+
input_state: state,
|
|
96
|
+
force: force
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def inject_exit(_writer, **_kwargs)
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def on_notification(&block)
|
|
105
|
+
@notification_handler = block
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def on_disconnect(&block)
|
|
109
|
+
@disconnect_handler = block
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
|
|
113
|
+
if read_io && write_io
|
|
114
|
+
@read_io = read_io
|
|
115
|
+
@write_io = write_io
|
|
116
|
+
@pid = pid
|
|
117
|
+
else
|
|
118
|
+
@pid, @write_io, @read_io = spawn_subprocess(env, cwd)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
@closed = false
|
|
122
|
+
@disconnect_signaled = false
|
|
123
|
+
@state = :prompt
|
|
124
|
+
@reader_thread = Thread.new { read_loop }
|
|
125
|
+
request_state_async
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def dispatch(prompt:, model: nil, effort: nil)
|
|
130
|
+
ensure_open!
|
|
131
|
+
|
|
132
|
+
payload = { "type" => "prompt", "message" => prompt.to_s }
|
|
133
|
+
payload["model"] = model if model
|
|
134
|
+
payload["thinkingLevel"] = effort if effort
|
|
135
|
+
|
|
136
|
+
request(payload)
|
|
137
|
+
@state = :busy
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def interrupt(turn_id: nil)
|
|
142
|
+
ensure_open!
|
|
143
|
+
request("type" => "abort")
|
|
144
|
+
rescue StandardError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def request_session_stats_async
|
|
149
|
+
return if @closed
|
|
150
|
+
return if @session_stats_requested
|
|
151
|
+
|
|
152
|
+
@session_stats_requested = true
|
|
153
|
+
write_line("type" => "get_session_stats")
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def respond_extension_ui_cancel(request_id:, method:)
|
|
159
|
+
return false unless DIALOG_UI_METHODS.include?(method.to_s)
|
|
160
|
+
|
|
161
|
+
write_line(
|
|
162
|
+
"type" => "extension_ui_response",
|
|
163
|
+
"id" => request_id,
|
|
164
|
+
"cancelled" => true
|
|
165
|
+
)
|
|
166
|
+
true
|
|
167
|
+
rescue StandardError
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def collect_session_summary
|
|
172
|
+
attempt_live_summary_refresh if connected?
|
|
173
|
+
|
|
174
|
+
@summary_mutex.synchronize do
|
|
175
|
+
{
|
|
176
|
+
input_tokens: @session_summary[:input_tokens],
|
|
177
|
+
output_tokens: @session_summary[:output_tokens],
|
|
178
|
+
reasoning_tokens: nil,
|
|
179
|
+
cached_tokens: @session_summary[:cached_tokens],
|
|
180
|
+
total_tokens: @session_summary[:total_tokens],
|
|
181
|
+
agent_session_id: @session_summary[:agent_session_id],
|
|
182
|
+
tool_calls: @session_summary[:tool_calls],
|
|
183
|
+
cost_usd: @session_summary[:cost_usd],
|
|
184
|
+
model: @session_summary[:model],
|
|
185
|
+
agent_provider: @session_summary[:agent_provider]
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def close
|
|
191
|
+
return if @closed
|
|
192
|
+
|
|
193
|
+
@closed = true
|
|
194
|
+
fail_pending_requests(StandardError.new("pi rpc client closed"))
|
|
195
|
+
|
|
196
|
+
begin
|
|
197
|
+
@write_io.close unless @write_io&.closed?
|
|
198
|
+
rescue IOError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
@reader_thread&.join(2)
|
|
203
|
+
ensure
|
|
204
|
+
terminate_subprocess(
|
|
205
|
+
term_grace_seconds: STOP_TERM_GRACE_SECONDS,
|
|
206
|
+
kill_grace_seconds: STOP_KILL_GRACE_SECONDS
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def terminate_subprocess(term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
|
|
211
|
+
return false unless @pid
|
|
212
|
+
|
|
213
|
+
begin
|
|
214
|
+
Process.kill("TERM", @pid)
|
|
215
|
+
rescue Errno::ESRCH
|
|
216
|
+
return true
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
return true if wait_for_process_exit(@pid, term_grace_seconds)
|
|
220
|
+
|
|
221
|
+
begin
|
|
222
|
+
Process.kill("KILL", @pid)
|
|
223
|
+
rescue Errno::ESRCH
|
|
224
|
+
return true
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
wait_for_process_exit(@pid, kill_grace_seconds)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def pid
|
|
231
|
+
@pid
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def request(payload)
|
|
237
|
+
raise "pi rpc client is closed" if @closed
|
|
238
|
+
|
|
239
|
+
queue = Queue.new
|
|
240
|
+
id = @id_mutex.synchronize do
|
|
241
|
+
assigned = @next_id
|
|
242
|
+
@next_id += 1
|
|
243
|
+
@pending[assigned] = queue
|
|
244
|
+
assigned
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
write_line(payload.merge("id" => id))
|
|
248
|
+
response = queue.pop
|
|
249
|
+
raise response if response.is_a?(Exception)
|
|
250
|
+
|
|
251
|
+
unless response["success"]
|
|
252
|
+
raise "pi rpc #{response["command"] || payload["type"]} failed: #{response["error"] || "unknown error"}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
handle_response_data(response)
|
|
256
|
+
response["data"] || {}
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def request_state_async
|
|
260
|
+
write_line("type" => "get_state")
|
|
261
|
+
rescue StandardError
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def attempt_live_summary_refresh
|
|
266
|
+
response = request("type" => "get_session_stats")
|
|
267
|
+
absorb_session_stats(response)
|
|
268
|
+
rescue StandardError
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def read_loop
|
|
273
|
+
buffer = +""
|
|
274
|
+
loop do
|
|
275
|
+
chunk = @read_io.readpartial(4096)
|
|
276
|
+
buffer << chunk
|
|
277
|
+
|
|
278
|
+
while (idx = buffer.index("\n"))
|
|
279
|
+
line = buffer.slice!(0, idx + 1)
|
|
280
|
+
line = line.chomp("\n")
|
|
281
|
+
line = line.chomp("\r")
|
|
282
|
+
next if line.strip.empty?
|
|
283
|
+
|
|
284
|
+
handle_line(line)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
rescue EOFError, IOError, Errno::EIO
|
|
288
|
+
nil
|
|
289
|
+
ensure
|
|
290
|
+
signal_disconnect(nil)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def handle_line(line)
|
|
294
|
+
message = JSON.parse(line)
|
|
295
|
+
handle_message(message)
|
|
296
|
+
rescue JSON::ParserError => e
|
|
297
|
+
signal_disconnect(e)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def handle_message(message)
|
|
301
|
+
if message["type"] == "response"
|
|
302
|
+
handle_response(message)
|
|
303
|
+
else
|
|
304
|
+
handle_event(message)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def handle_response(message)
|
|
309
|
+
pending = nil
|
|
310
|
+
if message.key?("id") && !message["id"].nil?
|
|
311
|
+
pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
if pending
|
|
315
|
+
pending.push(message)
|
|
316
|
+
return
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
handle_response_data(message)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def handle_response_data(message)
|
|
323
|
+
return unless message["type"] == "response" && message["success"]
|
|
324
|
+
|
|
325
|
+
case message["command"]
|
|
326
|
+
when "get_state"
|
|
327
|
+
absorb_state_data(message["data"])
|
|
328
|
+
when "get_session_stats"
|
|
329
|
+
absorb_session_stats(message["data"])
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def handle_event(message)
|
|
334
|
+
case message["type"]
|
|
335
|
+
when "agent_start", "turn_start"
|
|
336
|
+
@state = :busy
|
|
337
|
+
when "agent_end"
|
|
338
|
+
@state = :prompt
|
|
339
|
+
@last_completed_at = Time.now
|
|
340
|
+
request_session_stats_async
|
|
341
|
+
when "message_end"
|
|
342
|
+
absorb_model_from_message(message["message"])
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
@notification_handler&.call(message)
|
|
346
|
+
rescue StandardError
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def absorb_state_data(data)
|
|
351
|
+
return unless data.is_a?(Hash)
|
|
352
|
+
|
|
353
|
+
@state = data["isStreaming"] ? :busy : :prompt
|
|
354
|
+
@state = :busy if data["isCompacting"]
|
|
355
|
+
@summary_mutex.synchronize do
|
|
356
|
+
@session_summary[:agent_session_id] = data["sessionId"] if data["sessionId"]
|
|
357
|
+
end
|
|
358
|
+
absorb_model(data["model"])
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def absorb_session_stats(data)
|
|
362
|
+
return unless data.is_a?(Hash)
|
|
363
|
+
|
|
364
|
+
tokens = data["tokens"] || {}
|
|
365
|
+
@summary_mutex.synchronize do
|
|
366
|
+
@session_summary[:input_tokens] = numeric_or_nil(tokens["input"])
|
|
367
|
+
@session_summary[:output_tokens] = numeric_or_nil(tokens["output"])
|
|
368
|
+
@session_summary[:cached_tokens] = numeric_or_nil(tokens["cacheRead"])
|
|
369
|
+
@session_summary[:total_tokens] = numeric_or_nil(tokens["total"])
|
|
370
|
+
@session_summary[:tool_calls] = numeric_or_nil(data["toolCalls"])
|
|
371
|
+
@session_summary[:cost_usd] = float_or_nil(data["cost"])
|
|
372
|
+
@session_summary[:agent_session_id] = data["sessionId"] if data["sessionId"]
|
|
373
|
+
@session_summary[:model] = @model if @model
|
|
374
|
+
@session_summary[:agent_provider] = @provider if @provider
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def absorb_model_from_message(message)
|
|
379
|
+
return unless message.is_a?(Hash)
|
|
380
|
+
|
|
381
|
+
absorb_model({
|
|
382
|
+
"provider" => message["provider"],
|
|
383
|
+
"id" => message["model"]
|
|
384
|
+
})
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def absorb_model(model)
|
|
388
|
+
case model
|
|
389
|
+
when Hash
|
|
390
|
+
model_id = model["id"]
|
|
391
|
+
provider = model["provider"]
|
|
392
|
+
@model = model_id if model_id.is_a?(String) && !model_id.empty?
|
|
393
|
+
@provider = provider if provider.is_a?(String) && !provider.empty?
|
|
394
|
+
if @provider.nil? && @model&.include?("/")
|
|
395
|
+
@provider = @model.split("/", 2).first
|
|
396
|
+
end
|
|
397
|
+
when String
|
|
398
|
+
return if model.empty?
|
|
399
|
+
|
|
400
|
+
@model = model
|
|
401
|
+
@provider = model.split("/", 2).first if model.include?("/")
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def numeric_or_nil(value)
|
|
406
|
+
return nil if value.nil?
|
|
407
|
+
|
|
408
|
+
Integer(value)
|
|
409
|
+
rescue ArgumentError, TypeError
|
|
410
|
+
nil
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def float_or_nil(value)
|
|
414
|
+
return nil if value.nil?
|
|
415
|
+
|
|
416
|
+
Float(value)
|
|
417
|
+
rescue ArgumentError, TypeError
|
|
418
|
+
nil
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def write_line(payload)
|
|
422
|
+
raise "pi rpc client is closed" if @closed
|
|
423
|
+
|
|
424
|
+
@write_mutex.synchronize do
|
|
425
|
+
@write_io.write(JSON.generate(payload))
|
|
426
|
+
@write_io.write("\n")
|
|
427
|
+
@write_io.flush
|
|
428
|
+
end
|
|
429
|
+
rescue Errno::EPIPE, IOError => e
|
|
430
|
+
signal_disconnect(e)
|
|
431
|
+
raise
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def ensure_open!
|
|
435
|
+
raise "pi rpc client not started" unless @read_io && @write_io
|
|
436
|
+
raise "pi rpc disconnected" if @state == :disconnected
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def connected?
|
|
440
|
+
@read_io && @write_io && !@closed && @state != :disconnected
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def fail_pending_requests(error)
|
|
444
|
+
@id_mutex.synchronize do
|
|
445
|
+
@pending.each_value { |queue| queue.push(error) }
|
|
446
|
+
@pending.clear
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def signal_disconnect(error)
|
|
451
|
+
return if @disconnect_signaled
|
|
452
|
+
|
|
453
|
+
@disconnect_signaled = true
|
|
454
|
+
@state = :disconnected
|
|
455
|
+
fail_pending_requests(
|
|
456
|
+
error.is_a?(Exception) ? error : StandardError.new("pi rpc disconnected")
|
|
457
|
+
)
|
|
458
|
+
@disconnect_handler&.call(error)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def process_alive?(pid)
|
|
462
|
+
Process.kill(0, pid)
|
|
463
|
+
true
|
|
464
|
+
rescue Errno::ESRCH
|
|
465
|
+
false
|
|
466
|
+
rescue Errno::EPERM
|
|
467
|
+
true
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def wait_for_process_exit(pid, timeout_seconds)
|
|
471
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds.to_f
|
|
472
|
+
loop do
|
|
473
|
+
return true unless process_alive?(pid)
|
|
474
|
+
|
|
475
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
476
|
+
break if remaining <= 0
|
|
477
|
+
|
|
478
|
+
sleep([0.05, remaining].min)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
!process_alive?(pid)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def extract_initial_prompt(extra_args)
|
|
485
|
+
return nil unless extra_args.is_a?(Array)
|
|
486
|
+
|
|
487
|
+
prefixed = extra_args.find { |a| a.is_a?(String) && a.start_with?("[harnex session id=") }
|
|
488
|
+
return prefixed if prefixed && !prefixed.empty?
|
|
489
|
+
|
|
490
|
+
nil
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def cli_extra_args
|
|
494
|
+
@extra_args.reject { |a| a.is_a?(String) && a.start_with?("[harnex session id=") }
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def spawn_subprocess(env, cwd)
|
|
498
|
+
spawn_env = env || {}
|
|
499
|
+
opts = {}
|
|
500
|
+
opts[:chdir] = cwd if cwd
|
|
501
|
+
stdin_io, stdout_io, _stderr_io, wait_thr = Open3.popen3(spawn_env, *build_command, **opts)
|
|
502
|
+
[wait_thr.pid, stdin_io, stdout_io]
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def blocked_message(state, enter_only:)
|
|
506
|
+
return super if enter_only
|
|
507
|
+
|
|
508
|
+
"Pi RPC is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
data/lib/harnex/adapters.rb
CHANGED
|
@@ -3,6 +3,8 @@ require_relative "adapters/generic"
|
|
|
3
3
|
require_relative "adapters/codex"
|
|
4
4
|
require_relative "adapters/codex_appserver"
|
|
5
5
|
require_relative "adapters/claude"
|
|
6
|
+
require_relative "adapters/opencode"
|
|
7
|
+
require_relative "adapters/pi"
|
|
6
8
|
|
|
7
9
|
module Harnex
|
|
8
10
|
module Adapters
|
|
@@ -39,7 +41,9 @@ module Harnex
|
|
|
39
41
|
def registry
|
|
40
42
|
@registry ||= {
|
|
41
43
|
"claude" => Claude,
|
|
42
|
-
"codex" => Codex
|
|
44
|
+
"codex" => Codex,
|
|
45
|
+
"opencode" => Opencode,
|
|
46
|
+
"pi" => Pi
|
|
43
47
|
}
|
|
44
48
|
end
|
|
45
49
|
end
|
data/lib/harnex/cli.rb
CHANGED
|
@@ -23,6 +23,8 @@ module Harnex
|
|
|
23
23
|
Logs.new(@argv.drop(1)).run
|
|
24
24
|
when "events"
|
|
25
25
|
Events.new(@argv.drop(1)).run
|
|
26
|
+
when "history"
|
|
27
|
+
History.new(@argv.drop(1)).run
|
|
26
28
|
when "pane"
|
|
27
29
|
Pane.new(@argv.drop(1)).run
|
|
28
30
|
when "recipes"
|
|
@@ -65,6 +67,8 @@ module Harnex
|
|
|
65
67
|
Logs.usage
|
|
66
68
|
when "events"
|
|
67
69
|
Events.usage
|
|
70
|
+
when "history"
|
|
71
|
+
History.usage
|
|
68
72
|
when "pane"
|
|
69
73
|
Pane.usage
|
|
70
74
|
when "recipes"
|
|
@@ -90,6 +94,7 @@ module Harnex
|
|
|
90
94
|
harnex status [options]
|
|
91
95
|
harnex logs --id ID [options]
|
|
92
96
|
harnex events --id ID [options]
|
|
97
|
+
harnex history [options]
|
|
93
98
|
harnex pane --id ID [options]
|
|
94
99
|
harnex agents-guide [topic]
|
|
95
100
|
harnex doctor
|
|
@@ -103,12 +108,13 @@ module Harnex
|
|
|
103
108
|
status List live sessions
|
|
104
109
|
logs Read session output transcripts
|
|
105
110
|
events Stream per-session JSONL runtime events
|
|
111
|
+
history List completed dispatches from .harnex/dispatch.jsonl
|
|
106
112
|
pane Capture the current tmux pane for a live session
|
|
107
113
|
recipes List and read workflow recipes
|
|
108
114
|
guide Show the getting started guide
|
|
109
115
|
agents-guide
|
|
110
116
|
Show agent dispatch, chain, buddy, monitoring, and naming guides
|
|
111
|
-
doctor Run preflight checks
|
|
117
|
+
doctor Run preflight checks and optional read-only session sweep
|
|
112
118
|
help Show command help
|
|
113
119
|
|
|
114
120
|
New to harnex? Start with: harnex guide
|
|
@@ -119,12 +125,13 @@ module Harnex
|
|
|
119
125
|
Any other CLI name is launched with generic wrapping.
|
|
120
126
|
|
|
121
127
|
Examples:
|
|
122
|
-
harnex run
|
|
128
|
+
harnex run pi
|
|
123
129
|
harnex run aider --id blue-cat
|
|
124
130
|
harnex run codex -- --cd /path/to/repo
|
|
125
131
|
harnex status
|
|
126
132
|
harnex logs --id main --follow
|
|
127
133
|
harnex events --id main --snapshot
|
|
134
|
+
harnex history --limit 20
|
|
128
135
|
harnex pane --id main --lines 40
|
|
129
136
|
harnex agents-guide dispatch
|
|
130
137
|
harnex doctor
|