harnex 0.6.4 → 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 +170 -0
- data/README.md +71 -21
- data/TECHNICAL.md +24 -0
- data/guides/01_dispatch.md +22 -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 +85 -200
- data/lib/harnex/adapters/generic.rb +11 -0
- 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 +62 -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 +307 -46
- 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
|
|
@@ -33,6 +34,8 @@ module Harnex
|
|
|
33
34
|
].freeze
|
|
34
35
|
|
|
35
36
|
EVENTS = %w[task_complete turn_started item_completed disconnected].freeze
|
|
37
|
+
STOP_TERM_GRACE_SECONDS = 0.5
|
|
38
|
+
STOP_KILL_GRACE_SECONDS = 1.0
|
|
36
39
|
|
|
37
40
|
# Server→client approval requests harnex auto-approves so dispatched
|
|
38
41
|
# codex workers can run autonomously. Codex sends these via JSON-RPC
|
|
@@ -44,7 +47,7 @@ module Harnex
|
|
|
44
47
|
APPROVAL_RESPONSES = {
|
|
45
48
|
"applyPatchApproval" => { decision: "approved" },
|
|
46
49
|
"execCommandApproval" => { decision: "approved" },
|
|
47
|
-
"item/commandExecution/requestApproval" => { decision: "
|
|
50
|
+
"item/commandExecution/requestApproval" => { decision: "accept" },
|
|
48
51
|
"item/fileChange/requestApproval" => { decision: "accept" }
|
|
49
52
|
}.freeze
|
|
50
53
|
|
|
@@ -52,6 +55,7 @@ module Harnex
|
|
|
52
55
|
|
|
53
56
|
def initialize(extra_args = [])
|
|
54
57
|
super("codex", extra_args)
|
|
58
|
+
reject_unsupported_codex_flags!
|
|
55
59
|
@initial_prompt = extract_initial_prompt(extra_args)
|
|
56
60
|
@client = nil
|
|
57
61
|
@thread_id = nil
|
|
@@ -66,6 +70,10 @@ module Harnex
|
|
|
66
70
|
:stdio_jsonrpc
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
def provider
|
|
74
|
+
"openai"
|
|
75
|
+
end
|
|
76
|
+
|
|
69
77
|
def base_command
|
|
70
78
|
["codex", "app-server"]
|
|
71
79
|
end
|
|
@@ -132,10 +140,10 @@ module Harnex
|
|
|
132
140
|
# subprocess. In tests, callers may pass pre-built IO objects.
|
|
133
141
|
def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
|
|
134
142
|
if read_io && write_io
|
|
135
|
-
@client =
|
|
143
|
+
@client = Harnex::Codex::AppServer::Client.new(read_io: read_io, write_io: write_io, pid: pid)
|
|
136
144
|
else
|
|
137
145
|
spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
|
|
138
|
-
@client =
|
|
146
|
+
@client = Harnex::Codex::AppServer::Client.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
|
|
139
147
|
end
|
|
140
148
|
|
|
141
149
|
@client.on_notification { |msg| handle_notification(msg) }
|
|
@@ -165,7 +173,7 @@ module Harnex
|
|
|
165
173
|
params[:effort] = effort if effort
|
|
166
174
|
|
|
167
175
|
result = @client.request("turn/start", params)
|
|
168
|
-
@current_turn_id = result
|
|
176
|
+
@current_turn_id = result.dig("turn", "id")
|
|
169
177
|
@state = :busy
|
|
170
178
|
@current_turn_id
|
|
171
179
|
end
|
|
@@ -186,6 +194,48 @@ module Harnex
|
|
|
186
194
|
result
|
|
187
195
|
end
|
|
188
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
|
+
|
|
189
239
|
def close
|
|
190
240
|
return unless @client
|
|
191
241
|
|
|
@@ -194,6 +244,13 @@ module Harnex
|
|
|
194
244
|
@state = :disconnected
|
|
195
245
|
end
|
|
196
246
|
|
|
247
|
+
def terminate_subprocess(term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
|
|
248
|
+
@client&.terminate_process(
|
|
249
|
+
term_grace_seconds: term_grace_seconds,
|
|
250
|
+
kill_grace_seconds: kill_grace_seconds
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
197
254
|
def pid
|
|
198
255
|
@client&.pid
|
|
199
256
|
end
|
|
@@ -215,7 +272,7 @@ module Harnex
|
|
|
215
272
|
def extract_thread_id(payload)
|
|
216
273
|
return nil unless payload.is_a?(Hash)
|
|
217
274
|
|
|
218
|
-
payload.dig("thread", "id")
|
|
275
|
+
payload.dig("thread", "id")
|
|
219
276
|
end
|
|
220
277
|
|
|
221
278
|
def extract_initial_prompt(extra_args)
|
|
@@ -227,6 +284,21 @@ module Harnex
|
|
|
227
284
|
nil
|
|
228
285
|
end
|
|
229
286
|
|
|
287
|
+
# `codex app-server` does not implement `-m/--model`; passing it
|
|
288
|
+
# causes the subprocess to exit at startup, surfacing only as a
|
|
289
|
+
# null-message transport disconnect. Same flag still works on the
|
|
290
|
+
# legacy PTY adapter (`harnex run codex --legacy-pty`).
|
|
291
|
+
def reject_unsupported_codex_flags!
|
|
292
|
+
bad = @extra_args.find do |a|
|
|
293
|
+
s = a.to_s
|
|
294
|
+
s == "-m" || s == "--model" || s.start_with?("--model=")
|
|
295
|
+
end
|
|
296
|
+
return unless bad
|
|
297
|
+
|
|
298
|
+
raise ArgumentError,
|
|
299
|
+
"-m/--model is not supported by `codex app-server`. Use `-c model=\"<name>\"` instead."
|
|
300
|
+
end
|
|
301
|
+
|
|
230
302
|
# Codex CLI flags only — strips the harnex-context entry that
|
|
231
303
|
# `--context` smuggles through @extra_args.
|
|
232
304
|
def cli_extra_args
|
|
@@ -234,7 +306,12 @@ module Harnex
|
|
|
234
306
|
end
|
|
235
307
|
|
|
236
308
|
def perform_handshake
|
|
237
|
-
@client.request("initialize",
|
|
309
|
+
@client.request("initialize", handshake_initialize_params)
|
|
310
|
+
@client.notify("initialized", {})
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def handshake_initialize_params
|
|
314
|
+
{
|
|
238
315
|
clientInfo: {
|
|
239
316
|
title: CLIENT_TITLE,
|
|
240
317
|
name: CLIENT_NAME,
|
|
@@ -244,8 +321,7 @@ module Harnex
|
|
|
244
321
|
experimentalApi: false,
|
|
245
322
|
optOutNotificationMethods: OPT_OUT_NOTIFICATIONS
|
|
246
323
|
}
|
|
247
|
-
}
|
|
248
|
-
@client.notify("initialized", {})
|
|
324
|
+
}
|
|
249
325
|
end
|
|
250
326
|
|
|
251
327
|
def handle_notification(message)
|
|
@@ -256,7 +332,7 @@ module Harnex
|
|
|
256
332
|
when "thread/started"
|
|
257
333
|
@thread_id ||= extract_thread_id(params)
|
|
258
334
|
when "turn/started"
|
|
259
|
-
@current_turn_id = params
|
|
335
|
+
@current_turn_id = params.dig("turn", "id")
|
|
260
336
|
@state = :busy
|
|
261
337
|
when "turn/completed"
|
|
262
338
|
@last_completed_at = Time.now
|
|
@@ -289,197 +365,6 @@ module Harnex
|
|
|
289
365
|
"Codex app-server is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
|
|
290
366
|
end
|
|
291
367
|
|
|
292
|
-
# Minimal JSON-RPC 2.0 client. One JSON object per line.
|
|
293
|
-
# Responses keyed by id; everything else is a notification.
|
|
294
|
-
class JsonRpcClient
|
|
295
|
-
attr_reader :pid
|
|
296
|
-
|
|
297
|
-
def initialize(read_io:, write_io:, pid: nil)
|
|
298
|
-
@read_io = read_io
|
|
299
|
-
@write_io = write_io
|
|
300
|
-
@pid = pid
|
|
301
|
-
@next_id = 1
|
|
302
|
-
@pending = {}
|
|
303
|
-
@id_mutex = Mutex.new
|
|
304
|
-
@write_mutex = Mutex.new
|
|
305
|
-
@notification_handler = nil
|
|
306
|
-
@request_handler = nil
|
|
307
|
-
@disconnect_handler = nil
|
|
308
|
-
@disconnect_signaled = false
|
|
309
|
-
@closed = false
|
|
310
|
-
@reader_thread = nil
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
def on_notification(&block)
|
|
314
|
-
@notification_handler = block
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
# Handler for server-initiated requests (id + method). The block
|
|
318
|
-
# receives (method, params) and returns the response body for the
|
|
319
|
-
# JSON-RPC `result` field, or nil to reject with -32601.
|
|
320
|
-
def on_request(&block)
|
|
321
|
-
@request_handler = block
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def on_disconnect(&block)
|
|
325
|
-
@disconnect_handler = block
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
def start
|
|
329
|
-
@reader_thread = Thread.new { read_loop }
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
def request(method, params = {})
|
|
333
|
-
raise "codex_appserver client is closed" if @closed
|
|
334
|
-
|
|
335
|
-
queue = Queue.new
|
|
336
|
-
id = @id_mutex.synchronize do
|
|
337
|
-
assigned = @next_id
|
|
338
|
-
@next_id += 1
|
|
339
|
-
@pending[assigned] = queue
|
|
340
|
-
assigned
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
|
|
344
|
-
result = queue.pop
|
|
345
|
-
raise result if result.is_a?(Exception)
|
|
346
|
-
|
|
347
|
-
result
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
def notify(method, params = {})
|
|
351
|
-
return if @closed
|
|
352
|
-
|
|
353
|
-
write_line({ jsonrpc: "2.0", method: method, params: params })
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def close
|
|
357
|
-
return if @closed
|
|
358
|
-
|
|
359
|
-
@closed = true
|
|
360
|
-
|
|
361
|
-
@id_mutex.synchronize do
|
|
362
|
-
@pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
|
|
363
|
-
@pending.clear
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
begin
|
|
367
|
-
@write_io.close unless @write_io.closed?
|
|
368
|
-
rescue IOError
|
|
369
|
-
nil
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
if @pid && process_alive?(@pid)
|
|
373
|
-
sleep 0.05
|
|
374
|
-
begin
|
|
375
|
-
Process.kill("TERM", @pid)
|
|
376
|
-
rescue Errno::ESRCH
|
|
377
|
-
nil
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
@reader_thread&.join(2)
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
private
|
|
385
|
-
|
|
386
|
-
def write_line(message)
|
|
387
|
-
@write_mutex.synchronize do
|
|
388
|
-
@write_io.write(JSON.generate(message))
|
|
389
|
-
@write_io.write("\n")
|
|
390
|
-
@write_io.flush
|
|
391
|
-
end
|
|
392
|
-
rescue Errno::EPIPE, IOError
|
|
393
|
-
signal_disconnect(nil)
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
def read_loop
|
|
397
|
-
buffer = +""
|
|
398
|
-
loop do
|
|
399
|
-
chunk = @read_io.readpartial(4096)
|
|
400
|
-
buffer << chunk
|
|
401
|
-
while (idx = buffer.index("\n"))
|
|
402
|
-
line = buffer.slice!(0, idx + 1).chomp
|
|
403
|
-
next if line.strip.empty?
|
|
404
|
-
|
|
405
|
-
handle_line(line)
|
|
406
|
-
end
|
|
407
|
-
end
|
|
408
|
-
rescue EOFError, IOError, Errno::EIO
|
|
409
|
-
nil
|
|
410
|
-
ensure
|
|
411
|
-
signal_disconnect(nil)
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
def handle_line(line)
|
|
415
|
-
message = JSON.parse(line)
|
|
416
|
-
rescue JSON::ParserError => e
|
|
417
|
-
signal_disconnect(e)
|
|
418
|
-
return
|
|
419
|
-
else
|
|
420
|
-
dispatch_message(message)
|
|
421
|
-
end
|
|
422
|
-
|
|
423
|
-
def dispatch_message(message)
|
|
424
|
-
if message["id"] && message["method"]
|
|
425
|
-
handle_server_request(message)
|
|
426
|
-
return
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
if message.key?("id")
|
|
430
|
-
pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
|
|
431
|
-
return unless pending
|
|
432
|
-
|
|
433
|
-
if message["error"]
|
|
434
|
-
err_msg = message.dig("error", "message") || "RPC error"
|
|
435
|
-
pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
|
|
436
|
-
signal_disconnect(message["error"])
|
|
437
|
-
else
|
|
438
|
-
pending.push(message["result"] || {})
|
|
439
|
-
end
|
|
440
|
-
return
|
|
441
|
-
end
|
|
442
|
-
|
|
443
|
-
@notification_handler&.call(message) if message["method"]
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
def handle_server_request(message)
|
|
447
|
-
result =
|
|
448
|
-
begin
|
|
449
|
-
@request_handler&.call(message["method"], message["params"] || {})
|
|
450
|
-
rescue StandardError
|
|
451
|
-
nil
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
if result.nil?
|
|
455
|
-
write_line({
|
|
456
|
-
jsonrpc: "2.0",
|
|
457
|
-
id: message["id"],
|
|
458
|
-
error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
|
|
459
|
-
})
|
|
460
|
-
else
|
|
461
|
-
write_line({
|
|
462
|
-
jsonrpc: "2.0",
|
|
463
|
-
id: message["id"],
|
|
464
|
-
result: result
|
|
465
|
-
})
|
|
466
|
-
end
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
def signal_disconnect(error)
|
|
470
|
-
return if @disconnect_signaled
|
|
471
|
-
|
|
472
|
-
@disconnect_signaled = true
|
|
473
|
-
@disconnect_handler&.call(error)
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
def process_alive?(pid)
|
|
477
|
-
Process.kill(0, pid)
|
|
478
|
-
true
|
|
479
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
480
|
-
false
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
368
|
end
|
|
484
369
|
end
|
|
485
370
|
end
|
|
@@ -9,6 +9,17 @@ module Harnex
|
|
|
9
9
|
def base_command
|
|
10
10
|
[@cli_name]
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
def input_state(screen_text)
|
|
14
|
+
if recent_lines(screen_text).any? { |line| prompt_line?(line) }
|
|
15
|
+
{
|
|
16
|
+
state: "prompt",
|
|
17
|
+
input_ready: true
|
|
18
|
+
}
|
|
19
|
+
else
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
12
23
|
end
|
|
13
24
|
end
|
|
14
25
|
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
|