harnex 0.7.3 → 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 +20 -0
- data/README.md +55 -36
- data/guides/01_dispatch.md +24 -23
- 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 +3 -2
- data/lib/harnex/adapters/pi.rb +512 -0
- data/lib/harnex/adapters.rb +3 -1
- data/lib/harnex/cli.rb +1 -1
- data/lib/harnex/commands/run.rb +3 -3
- data/lib/harnex/runtime/session.rb +164 -23
- data/lib/harnex/version.rb +2 -2
- metadata +5 -4
|
@@ -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
|
@@ -4,6 +4,7 @@ require_relative "adapters/codex"
|
|
|
4
4
|
require_relative "adapters/codex_appserver"
|
|
5
5
|
require_relative "adapters/claude"
|
|
6
6
|
require_relative "adapters/opencode"
|
|
7
|
+
require_relative "adapters/pi"
|
|
7
8
|
|
|
8
9
|
module Harnex
|
|
9
10
|
module Adapters
|
|
@@ -41,7 +42,8 @@ module Harnex
|
|
|
41
42
|
@registry ||= {
|
|
42
43
|
"claude" => Claude,
|
|
43
44
|
"codex" => Codex,
|
|
44
|
-
"opencode" => Opencode
|
|
45
|
+
"opencode" => Opencode,
|
|
46
|
+
"pi" => Pi
|
|
45
47
|
}
|
|
46
48
|
end
|
|
47
49
|
end
|
data/lib/harnex/cli.rb
CHANGED
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -55,9 +55,9 @@ module Harnex
|
|
|
55
55
|
Wrapper options may appear before or after <cli>.
|
|
56
56
|
|
|
57
57
|
Common patterns:
|
|
58
|
-
#{program_name}
|
|
59
|
-
#{program_name}
|
|
60
|
-
#{program_name}
|
|
58
|
+
#{program_name} pi --id pi-i-42 --tmux pi-i-42 --context "Read /tmp/task-impl-42.md"
|
|
59
|
+
#{program_name} pi --id pi-i-42 --tmux pi-i-42 --context "Read /tmp/task-impl-42.md" --auto-stop
|
|
60
|
+
#{program_name} pi --id pi-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
|
|
61
61
|
#{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
|
|
62
62
|
|
|
63
63
|
Gotchas:
|