harnex 0.6.5 → 0.7.3
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 +138 -0
- data/README.md +71 -21
- data/TECHNICAL.md +23 -0
- data/guides/01_dispatch.md +2 -0
- data/lib/harnex/adapters/base.rb +33 -0
- 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.rb +3 -1
- data/lib/harnex/cli.rb +8 -1
- 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 +44 -6
- 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 +164 -23
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -0
- metadata +6 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "open3"
|
|
3
|
+
require_relative "../codex/app_server/client"
|
|
3
4
|
|
|
4
5
|
module Harnex
|
|
5
6
|
module Adapters
|
|
@@ -69,6 +70,10 @@ module Harnex
|
|
|
69
70
|
:stdio_jsonrpc
|
|
70
71
|
end
|
|
71
72
|
|
|
73
|
+
def provider
|
|
74
|
+
"openai"
|
|
75
|
+
end
|
|
76
|
+
|
|
72
77
|
def base_command
|
|
73
78
|
["codex", "app-server"]
|
|
74
79
|
end
|
|
@@ -135,10 +140,10 @@ module Harnex
|
|
|
135
140
|
# subprocess. In tests, callers may pass pre-built IO objects.
|
|
136
141
|
def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
|
|
137
142
|
if read_io && write_io
|
|
138
|
-
@client =
|
|
143
|
+
@client = Harnex::Codex::AppServer::Client.new(read_io: read_io, write_io: write_io, pid: pid)
|
|
139
144
|
else
|
|
140
145
|
spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
|
|
141
|
-
@client =
|
|
146
|
+
@client = Harnex::Codex::AppServer::Client.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
|
|
142
147
|
end
|
|
143
148
|
|
|
144
149
|
@client.on_notification { |msg| handle_notification(msg) }
|
|
@@ -189,6 +194,48 @@ module Harnex
|
|
|
189
194
|
result
|
|
190
195
|
end
|
|
191
196
|
|
|
197
|
+
# Plan 30 Phase 2 — subprocess-restart primitive for deployment
|
|
198
|
+
# fallback. Stops the current JSON-RPC subprocess, spawns a new one
|
|
199
|
+
# against the supplied deployment_config, and resumes the same
|
|
200
|
+
# threadId so conversation state carries across. Thin orchestrator:
|
|
201
|
+
# counter snapshots, the `fallback_triggered` event, and any
|
|
202
|
+
# Session-level signaling land in plan 30 Phases 3–4 alongside the
|
|
203
|
+
# per-arm telemetry split. CLI flags land in Phase 5.
|
|
204
|
+
#
|
|
205
|
+
# deployment_config: { command: [...argv], env: {...}, cwd: nil }
|
|
206
|
+
def switch_deployment(deployment_config:,
|
|
207
|
+
term_grace_seconds: STOP_TERM_GRACE_SECONDS,
|
|
208
|
+
kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
|
|
209
|
+
raise "codex_appserver: client not started" unless @client
|
|
210
|
+
raise "codex_appserver: no thread to resume" if @thread_id.nil? || @thread_id.to_s.empty?
|
|
211
|
+
|
|
212
|
+
prior_thread_id = @thread_id
|
|
213
|
+
in_flight =
|
|
214
|
+
if @current_turn_id
|
|
215
|
+
{ threadId: prior_thread_id, turnId: @current_turn_id }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
@client.stop_for_fallback(
|
|
219
|
+
in_flight_turn: in_flight,
|
|
220
|
+
term_grace_seconds: term_grace_seconds,
|
|
221
|
+
kill_grace_seconds: kill_grace_seconds
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@client = Harnex::Codex::AppServer::Client.spawn_with_fallback(
|
|
225
|
+
prior_thread_id: prior_thread_id,
|
|
226
|
+
deployment_config: deployment_config,
|
|
227
|
+
handshake_params: handshake_initialize_params,
|
|
228
|
+
notification_handler: ->(msg) { handle_notification(msg) },
|
|
229
|
+
request_handler: ->(method, params) { handle_server_request(method, params) },
|
|
230
|
+
disconnect_handler: ->(err) { handle_disconnect(err) }
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
@thread_id = prior_thread_id
|
|
234
|
+
@current_turn_id = nil
|
|
235
|
+
@state = :prompt
|
|
236
|
+
self
|
|
237
|
+
end
|
|
238
|
+
|
|
192
239
|
def close
|
|
193
240
|
return unless @client
|
|
194
241
|
|
|
@@ -259,7 +306,12 @@ module Harnex
|
|
|
259
306
|
end
|
|
260
307
|
|
|
261
308
|
def perform_handshake
|
|
262
|
-
@client.request("initialize",
|
|
309
|
+
@client.request("initialize", handshake_initialize_params)
|
|
310
|
+
@client.notify("initialized", {})
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def handshake_initialize_params
|
|
314
|
+
{
|
|
263
315
|
clientInfo: {
|
|
264
316
|
title: CLIENT_TITLE,
|
|
265
317
|
name: CLIENT_NAME,
|
|
@@ -269,8 +321,7 @@ module Harnex
|
|
|
269
321
|
experimentalApi: false,
|
|
270
322
|
optOutNotificationMethods: OPT_OUT_NOTIFICATIONS
|
|
271
323
|
}
|
|
272
|
-
}
|
|
273
|
-
@client.notify("initialized", {})
|
|
324
|
+
}
|
|
274
325
|
end
|
|
275
326
|
|
|
276
327
|
def handle_notification(message)
|
|
@@ -314,231 +365,6 @@ module Harnex
|
|
|
314
365
|
"Codex app-server is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
|
|
315
366
|
end
|
|
316
367
|
|
|
317
|
-
# Minimal JSON-RPC 2.0 client. One JSON object per line.
|
|
318
|
-
# Responses keyed by id; everything else is a notification.
|
|
319
|
-
class JsonRpcClient
|
|
320
|
-
attr_reader :pid
|
|
321
|
-
|
|
322
|
-
def initialize(read_io:, write_io:, pid: nil)
|
|
323
|
-
@read_io = read_io
|
|
324
|
-
@write_io = write_io
|
|
325
|
-
@pid = pid
|
|
326
|
-
@next_id = 1
|
|
327
|
-
@pending = {}
|
|
328
|
-
@id_mutex = Mutex.new
|
|
329
|
-
@write_mutex = Mutex.new
|
|
330
|
-
@notification_handler = nil
|
|
331
|
-
@request_handler = nil
|
|
332
|
-
@disconnect_handler = nil
|
|
333
|
-
@disconnect_signaled = false
|
|
334
|
-
@closed = false
|
|
335
|
-
@reader_thread = nil
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
def on_notification(&block)
|
|
339
|
-
@notification_handler = block
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
# Handler for server-initiated requests (id + method). The block
|
|
343
|
-
# receives (method, params) and returns the response body for the
|
|
344
|
-
# JSON-RPC `result` field, or nil to reject with -32601.
|
|
345
|
-
def on_request(&block)
|
|
346
|
-
@request_handler = block
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
def on_disconnect(&block)
|
|
350
|
-
@disconnect_handler = block
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
def start
|
|
354
|
-
@reader_thread = Thread.new { read_loop }
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
def request(method, params = {})
|
|
358
|
-
raise "codex_appserver client is closed" if @closed
|
|
359
|
-
|
|
360
|
-
queue = Queue.new
|
|
361
|
-
id = @id_mutex.synchronize do
|
|
362
|
-
assigned = @next_id
|
|
363
|
-
@next_id += 1
|
|
364
|
-
@pending[assigned] = queue
|
|
365
|
-
assigned
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
|
|
369
|
-
result = queue.pop
|
|
370
|
-
raise result if result.is_a?(Exception)
|
|
371
|
-
|
|
372
|
-
result
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
def notify(method, params = {})
|
|
376
|
-
return if @closed
|
|
377
|
-
|
|
378
|
-
write_line({ jsonrpc: "2.0", method: method, params: params })
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
def close
|
|
382
|
-
return if @closed
|
|
383
|
-
|
|
384
|
-
@closed = true
|
|
385
|
-
|
|
386
|
-
@id_mutex.synchronize do
|
|
387
|
-
@pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
|
|
388
|
-
@pending.clear
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
begin
|
|
392
|
-
@write_io.close unless @write_io.closed?
|
|
393
|
-
rescue IOError
|
|
394
|
-
nil
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
if @pid && process_alive?(@pid)
|
|
398
|
-
sleep 0.05
|
|
399
|
-
begin
|
|
400
|
-
Process.kill("TERM", @pid)
|
|
401
|
-
rescue Errno::ESRCH
|
|
402
|
-
nil
|
|
403
|
-
end
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
@reader_thread&.join(2)
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
def terminate_process(term_grace_seconds:, kill_grace_seconds:)
|
|
410
|
-
return false unless @pid
|
|
411
|
-
|
|
412
|
-
begin
|
|
413
|
-
Process.kill("TERM", @pid)
|
|
414
|
-
rescue Errno::ESRCH
|
|
415
|
-
return true
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
return true if wait_for_process_exit(@pid, term_grace_seconds)
|
|
419
|
-
|
|
420
|
-
begin
|
|
421
|
-
Process.kill("KILL", @pid)
|
|
422
|
-
rescue Errno::ESRCH
|
|
423
|
-
return true
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
wait_for_process_exit(@pid, kill_grace_seconds)
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
private
|
|
430
|
-
|
|
431
|
-
def write_line(message)
|
|
432
|
-
@write_mutex.synchronize do
|
|
433
|
-
@write_io.write(JSON.generate(message))
|
|
434
|
-
@write_io.write("\n")
|
|
435
|
-
@write_io.flush
|
|
436
|
-
end
|
|
437
|
-
rescue Errno::EPIPE, IOError
|
|
438
|
-
signal_disconnect(nil)
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
def read_loop
|
|
442
|
-
buffer = +""
|
|
443
|
-
loop do
|
|
444
|
-
chunk = @read_io.readpartial(4096)
|
|
445
|
-
buffer << chunk
|
|
446
|
-
while (idx = buffer.index("\n"))
|
|
447
|
-
line = buffer.slice!(0, idx + 1).chomp
|
|
448
|
-
next if line.strip.empty?
|
|
449
|
-
|
|
450
|
-
handle_line(line)
|
|
451
|
-
end
|
|
452
|
-
end
|
|
453
|
-
rescue EOFError, IOError, Errno::EIO
|
|
454
|
-
nil
|
|
455
|
-
ensure
|
|
456
|
-
signal_disconnect(nil)
|
|
457
|
-
end
|
|
458
|
-
|
|
459
|
-
def handle_line(line)
|
|
460
|
-
message = JSON.parse(line)
|
|
461
|
-
rescue JSON::ParserError => e
|
|
462
|
-
signal_disconnect(e)
|
|
463
|
-
return
|
|
464
|
-
else
|
|
465
|
-
dispatch_message(message)
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
def dispatch_message(message)
|
|
469
|
-
if message["id"] && message["method"]
|
|
470
|
-
handle_server_request(message)
|
|
471
|
-
return
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
if message.key?("id")
|
|
475
|
-
pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
|
|
476
|
-
return unless pending
|
|
477
|
-
|
|
478
|
-
if message["error"]
|
|
479
|
-
err_msg = message.dig("error", "message") || "RPC error"
|
|
480
|
-
pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
|
|
481
|
-
signal_disconnect(message["error"])
|
|
482
|
-
else
|
|
483
|
-
pending.push(message["result"] || {})
|
|
484
|
-
end
|
|
485
|
-
return
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
@notification_handler&.call(message) if message["method"]
|
|
489
|
-
end
|
|
490
|
-
|
|
491
|
-
def handle_server_request(message)
|
|
492
|
-
result =
|
|
493
|
-
begin
|
|
494
|
-
@request_handler&.call(message["method"], message["params"] || {})
|
|
495
|
-
rescue StandardError
|
|
496
|
-
nil
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
if result.nil?
|
|
500
|
-
write_line({
|
|
501
|
-
jsonrpc: "2.0",
|
|
502
|
-
id: message["id"],
|
|
503
|
-
error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
|
|
504
|
-
})
|
|
505
|
-
else
|
|
506
|
-
write_line({
|
|
507
|
-
jsonrpc: "2.0",
|
|
508
|
-
id: message["id"],
|
|
509
|
-
result: result
|
|
510
|
-
})
|
|
511
|
-
end
|
|
512
|
-
end
|
|
513
|
-
|
|
514
|
-
def signal_disconnect(error)
|
|
515
|
-
return if @disconnect_signaled
|
|
516
|
-
|
|
517
|
-
@disconnect_signaled = true
|
|
518
|
-
@disconnect_handler&.call(error)
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
def process_alive?(pid)
|
|
522
|
-
Process.kill(0, pid)
|
|
523
|
-
true
|
|
524
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
525
|
-
false
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
def wait_for_process_exit(pid, timeout_seconds)
|
|
529
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds.to_f
|
|
530
|
-
loop do
|
|
531
|
-
return true unless process_alive?(pid)
|
|
532
|
-
|
|
533
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
534
|
-
break if remaining <= 0
|
|
535
|
-
|
|
536
|
-
sleep([0.05, remaining].min)
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
!process_alive?(pid)
|
|
540
|
-
end
|
|
541
|
-
end
|
|
542
368
|
end
|
|
543
369
|
end
|
|
544
370
|
end
|
|
@@ -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
|
data/lib/harnex/adapters.rb
CHANGED
|
@@ -3,6 +3,7 @@ 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"
|
|
6
7
|
|
|
7
8
|
module Harnex
|
|
8
9
|
module Adapters
|
|
@@ -39,7 +40,8 @@ module Harnex
|
|
|
39
40
|
def registry
|
|
40
41
|
@registry ||= {
|
|
41
42
|
"claude" => Claude,
|
|
42
|
-
"codex" => Codex
|
|
43
|
+
"codex" => Codex,
|
|
44
|
+
"opencode" => Opencode
|
|
43
45
|
}
|
|
44
46
|
end
|
|
45
47
|
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
|
|
@@ -125,6 +131,7 @@ module Harnex
|
|
|
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
|