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.
data/guides/03_buddy.md CHANGED
@@ -7,7 +7,7 @@ needs interpretation that simple stall policy cannot provide.
7
7
  For simple inactivity recovery, prefer built-in watch mode:
8
8
 
9
9
  ```bash
10
- harnex run codex --id cx-i-NN --watch --preset impl --context "Read /tmp/task-impl-NN.md"
10
+ harnex run pi --id pi-i-NN --watch --preset impl --context "Read /tmp/task-impl-NN.md"
11
11
  ```
12
12
 
13
13
  Use a buddy when you need reasoning over pane contents, semantic checks, or
@@ -28,14 +28,15 @@ Use a buddy for:
28
28
  Spawn the worker first, then spawn the buddy:
29
29
 
30
30
  ```bash
31
- harnex run codex --id cx-i-42 --tmux cx-i-42 \
31
+ harnex run pi --id pi-i-42 --tmux pi-i-42 \
32
32
  --context "Read and execute /tmp/task-impl-42.md"
33
33
 
34
- harnex run claude --id buddy-42 --tmux buddy-42
34
+ harnex run pi --id buddy-42 --tmux buddy-42
35
35
  ```
36
36
 
37
37
  The buddy is just another harnex session. Inspect it, stop it, and read its
38
- logs with the same commands as any worker.
38
+ logs with the same commands as any worker. Use Pi by default; swap to another
39
+ adapter if your environment or policy requires it.
39
40
 
40
41
  ## Buddy Prompt
41
42
 
@@ -43,18 +44,18 @@ Give the buddy an explicit polling loop, stall threshold, nudge rule, return
43
44
  channel, and cleanup rule:
44
45
 
45
46
  ```text
46
- You are an accountability partner for harnex session `cx-i-42`.
47
+ You are an accountability partner for harnex session `pi-i-42`.
47
48
 
48
49
  Every 5 minutes:
49
- - Run `harnex pane --id cx-i-42 --lines 30`.
50
- - Run `harnex status --id cx-i-42 --json`.
50
+ - Run `harnex pane --id pi-i-42 --lines 30`.
51
+ - Run `harnex status --id pi-i-42 --json`.
51
52
 
52
53
  If the worker appears stuck at a prompt or permission dialog for more than
53
54
  10 minutes with no progress, nudge it:
54
- - `harnex send --id cx-i-42 --message "You appear to have stalled. Continue with your current task."`
55
+ - `harnex send --id pi-i-42 --message "You appear to have stalled. Continue with your current task."`
55
56
 
56
- If the worker exits or writes `/tmp/cx-i-42-done.txt`, report back:
57
- - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "cx-i-42 finished. Check /tmp/cx-i-42-done.txt." Enter`
57
+ If the worker exits or writes `/tmp/pi-i-42-done.txt`, report back:
58
+ - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 finished. Check /tmp/pi-i-42-done.txt." Enter`
58
59
 
59
60
  Do not interfere with active work. Stop yourself after reporting completion.
60
61
  ```
@@ -73,7 +74,7 @@ harnex session:
73
74
 
74
75
  ```bash
75
76
  tmux capture-pane -t "$HARNEX_SPAWNER_PANE" -p
76
- tmux send-keys -t "$HARNEX_SPAWNER_PANE" "cx-i-42 finished" Enter
77
+ tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 finished" Enter
77
78
  ```
78
79
 
79
80
  If no tmux return pane is available, require the buddy to write a file and tell
@@ -17,16 +17,17 @@ Prefer signals in this order:
17
17
  | `harnex pane` | Live UI interpretation and prompt/error diagnosis |
18
18
  | `harnex status` | Session liveness and coarse state |
19
19
 
20
- For Codex app-server sessions, `harnex wait --until task_complete` is a strong
21
- turn-level fence. It still does not know your acceptance criteria; verify the
22
- expected artifact or tests afterward.
20
+ For structured sessions (Pi RPC and Codex app-server),
21
+ `harnex wait --until task_complete` is a strong turn-level fence. It still
22
+ does not know your acceptance criteria; verify the expected artifact or tests
23
+ afterward.
23
24
 
24
25
  ## Completion Test
25
26
 
26
27
  For unattended work, declare done with a conjunction of work-level facts:
27
28
 
28
29
  ```bash
29
- test -f /tmp/cx-i-NN-done.txt &&
30
+ test -f /tmp/pi-i-NN-done.txt &&
30
31
  test -z "$(git status --short)" &&
31
32
  test "$(git log -1 --format=%ct)" -lt "$(($(date +%s) - 600))"
32
33
  ```
@@ -51,23 +52,23 @@ where to look.
51
52
  For active supervision:
52
53
 
53
54
  ```bash
54
- harnex pane --id cx-i-NN --lines 40
55
- harnex events --id cx-i-NN --snapshot
56
- harnex logs --id cx-i-NN --lines 80
55
+ harnex pane --id pi-i-NN --lines 40
56
+ harnex events --id pi-i-NN --snapshot
57
+ harnex logs --id pi-i-NN --lines 80
57
58
  ```
58
59
 
59
60
  For continuous viewing:
60
61
 
61
62
  ```bash
62
- harnex pane --id cx-i-NN --follow --interval 2
63
- harnex logs --id cx-i-NN --follow
64
- harnex events --id cx-i-NN
63
+ harnex pane --id pi-i-NN --follow --interval 2
64
+ harnex logs --id pi-i-NN --follow
65
+ harnex events --id pi-i-NN
65
66
  ```
66
67
 
67
68
  For task completion:
68
69
 
69
70
  ```bash
70
- harnex wait --id cx-i-NN --until task_complete --timeout 900
71
+ harnex wait --id pi-i-NN --until task_complete --timeout 900
71
72
  ```
72
73
 
73
74
  ## Background Sweeper
@@ -80,14 +81,14 @@ pipeline cannot wait forever:
80
81
  start=$(date +%s)
81
82
  max_wait=5400
82
83
 
83
- until test -f /tmp/cx-i-NN-done.txt; do
84
+ until test -f /tmp/pi-i-NN-done.txt; do
84
85
  if test "$(($(date +%s) - start))" -gt "$max_wait"; then
85
- echo "wall-clock cap hit for cx-i-NN" >&2
86
+ echo "wall-clock cap hit for pi-i-NN" >&2
86
87
  exit 2
87
88
  fi
88
89
 
89
- harnex status --id cx-i-NN --json
90
- harnex pane --id cx-i-NN --lines 20
90
+ harnex status --id pi-i-NN --json
91
+ harnex pane --id pi-i-NN --lines 20
91
92
  sleep 60
92
93
  done
93
94
  ```
@@ -106,7 +107,7 @@ Use `harnex run --watch` when one foreground process should launch the worker
106
107
  and apply bounded stall recovery:
107
108
 
108
109
  ```bash
109
- harnex run codex --id cx-i-NN --watch --preset impl \
110
+ harnex run pi --id pi-i-NN --watch --preset impl \
110
111
  --context "Read /tmp/task-impl-NN.md"
111
112
  ```
112
113
 
data/guides/05_naming.md CHANGED
@@ -15,6 +15,7 @@ Common prefixes:
15
15
 
16
16
  | Prefix | Meaning |
17
17
  | --- | --- |
18
+ | `pi` | Pi worker (default) |
18
19
  | `cx` | Codex worker |
19
20
  | `cl` | Claude worker |
20
21
  | `buddy` | Buddy monitor |
@@ -34,13 +35,13 @@ Common phases:
34
35
  Examples:
35
36
 
36
37
  ```text
37
- cx-m-42 Codex maps task 42
38
- cx-p-42 Codex writes plan 42
39
- cx-r-42 Codex reviews plan 42
40
- cx-f-42 Codex fixes plan 42
41
- cx-i-42 Codex implements plan 42
42
- cx-cr-42 Codex reviews implementation 42
43
- cx-cf-42 Codex fixes implementation 42
38
+ pi-m-42 Pi maps task 42
39
+ pi-p-42 Pi writes plan 42
40
+ pi-r-42 Pi reviews plan 42
41
+ pi-f-42 Pi fixes plan 42
42
+ pi-i-42 Pi implements plan 42
43
+ pi-cr-42 Pi reviews implementation 42
44
+ pi-cf-42 Pi fixes implementation 42
44
45
  buddy-42 Buddy monitors task 42
45
46
  ```
46
47
 
@@ -52,13 +53,13 @@ short, and present in every artifact.
52
53
  Always pass both and keep them identical:
53
54
 
54
55
  ```bash
55
- harnex run codex --id cx-i-42 --tmux cx-i-42
56
+ harnex run pi --id pi-i-42 --tmux pi-i-42
56
57
  ```
57
58
 
58
59
  Avoid this:
59
60
 
60
61
  ```bash
61
- harnex run codex --tmux cx-i-42
62
+ harnex run pi --tmux pi-i-42
62
63
  ```
63
64
 
64
65
  If `--id` is missing, harnex generates a random session ID. The tmux window may
@@ -70,9 +71,9 @@ ID.
70
71
  If a session fails and you dispatch a fresh attempt, append a suffix:
71
72
 
72
73
  ```text
73
- cx-i-42 first attempt
74
- cx-i-42b second attempt
75
- cx-i-42c third attempt
74
+ pi-i-42 first attempt
75
+ pi-i-42b second attempt
76
+ pi-i-42c third attempt
76
77
  ```
77
78
 
78
79
  Keep the old session's logs. They are useful for diagnosis.
@@ -97,9 +98,9 @@ session ID.
97
98
  Derive done markers from the session ID:
98
99
 
99
100
  ```text
100
- /tmp/cx-p-42-done.txt
101
- /tmp/cx-i-42-done.txt
102
- /tmp/cx-cr-42-done.txt
101
+ /tmp/pi-p-42-done.txt
102
+ /tmp/pi-i-42-done.txt
103
+ /tmp/pi-cr-42-done.txt
103
104
  ```
104
105
 
105
106
  When a brief asks for a completion marker, make it one line and include the
@@ -1,7 +1,11 @@
1
+ require "open3"
2
+ require "timeout"
3
+
1
4
  module Harnex
2
5
  module Adapters
3
6
  class Base
4
7
  PROMPT_PREFIXES = [">", "\u203A", "\u276F"].freeze
8
+ AGENT_VERSION_TIMEOUT_SECONDS = 2.0
5
9
 
6
10
  # Adapter contract — subclasses MUST implement:
7
11
  # base_command -> Array[String] CLI args to spawn
@@ -20,12 +24,28 @@ module Harnex
20
24
  @extra_args = extra_args.dup
21
25
  end
22
26
 
23
- # Default transport. Adapters speaking JSON-RPC override to
24
- # :stdio_jsonrpc; Session#run uses this to pick the I/O path.
27
+ # Default transport. Structured adapters override to :stdio_jsonrpc
28
+ # (Codex app-server) or :stdio_jsonl_rpc (Pi RPC); Session#run uses
29
+ # this to pick the I/O path.
25
30
  def transport
26
31
  :pty
27
32
  end
28
33
 
34
+ # Vendor of the underlying agent — populates DISPATCH meta.agent_provider.
35
+ # Subclasses override (claude → "anthropic", codex → "openai").
36
+ def provider
37
+ nil
38
+ end
39
+
40
+ # Probes `<base_command.first> --version` with a short timeout and
41
+ # memoizes the result for the adapter's lifetime. Returns nil when
42
+ # the binary is missing, exits non-zero, or stalls past the timeout.
43
+ def agent_version
44
+ return @agent_version if defined?(@agent_version)
45
+
46
+ @agent_version = probe_agent_version
47
+ end
48
+
29
49
  def describe
30
50
  { transport: transport }
31
51
  end
@@ -108,6 +128,20 @@ module Harnex
108
128
 
109
129
  protected
110
130
 
131
+ def probe_agent_version
132
+ cli = base_command.first
133
+ return nil unless cli
134
+
135
+ Timeout.timeout(AGENT_VERSION_TIMEOUT_SECONDS) do
136
+ stdout, status = Open3.capture2(cli, "--version", err: File::NULL, in: File::NULL)
137
+ return nil unless status.success?
138
+
139
+ stdout.to_s.lines.first&.strip
140
+ end
141
+ rescue Errno::ENOENT, Timeout::Error, StandardError
142
+ nil
143
+ end
144
+
111
145
  def submit_bytes
112
146
  "\r"
113
147
  end
@@ -8,6 +8,10 @@ module Harnex
8
8
  super("claude", extra_args)
9
9
  end
10
10
 
11
+ def provider
12
+ "anthropic"
13
+ end
14
+
11
15
  def base_command
12
16
  [
13
17
  "claude",
@@ -10,6 +10,10 @@ module Harnex
10
10
  @banner_seen = false
11
11
  end
12
12
 
13
+ def provider
14
+ "openai"
15
+ end
16
+
13
17
  def base_command
14
18
  [
15
19
  "codex",
@@ -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 = JsonRpcClient.new(read_io: read_io, write_io: write_io, pid: pid)
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 = JsonRpcClient.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
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