harnex 0.6.4 → 0.6.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 354a4709831de10c8d19c40fbc4efa5b6df3d3fb1d0459fe1f4960b986eef8ad
4
- data.tar.gz: 43e0617dac379a90281798fb7f1bd6973177111341d25cd29a1f0c6166580ada
3
+ metadata.gz: 297484ce749d268b7582afd226d617c79fed80a12928ced5daa8df2c3bd4f763
4
+ data.tar.gz: 554528411e6bca5c6037ca579067f37b4b58626b88ed5c263fe95debe4517f70
5
5
  SHA512:
6
- metadata.gz: 3213b7c38243f315b9cb3dfed677c63211d7148e166393f8b599088c14d7d0128ebfd0e583068182c0fff8419e483abd8f0e0f2230c37a25beb4bb6d812d0463
7
- data.tar.gz: 1aeb182b6e355df8dce1f8445ebfcc3bedea8c7a5655590ca3eeac900b5fdcbef38786a88ef9dc8d3d0eaf2717dc330a3e34e98965ef18af6ef6f5b105d50e95
6
+ metadata.gz: 1aa029fe0d4177ef1a9043c863935655df9b636b48bedaad3f84d35e6d02d0b9255b16ce2c08a5220f2eb4b89930fe0002b2926c47039b7cb1eeab2a6e333b07
7
+ data.tar.gz: ab908f0f299a9a0ae286cf629202fa26d518f59658518515818c388f21daadbd42a4a839417878a99c3f863439df5d11dd51cdf7c3675b737638293b5c739832
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.5] — 2026-05-07
6
+
7
+ ### Added
8
+
9
+ - `harnex run --auto-stop` for one-shot `--context` dispatches.
10
+ JSON-RPC Codex sessions stop after the first `task_complete` event,
11
+ while PTY-backed sessions stop after the initial context turn returns
12
+ to a prompt. The shutdown path reuses `harnex stop`, including the
13
+ JSON-RPC TERM/KILL fallback.
14
+
15
+ ### Fixed
16
+
17
+ - JSON-RPC Codex sessions now terminate their `codex app-server`
18
+ subprocess on `harnex stop`: harnex preserves the existing
19
+ `turn/interrupt` request, then sends TERM with a bounded KILL
20
+ fallback so the runner can release the API port and registry entry.
21
+ - JSON-RPC adapter now captures token usage in `DISPATCH.jsonl`.
22
+ `Session#handle_rpc_notification` reads schema-true
23
+ `params["tokenUsage"]` on `thread/tokenUsage/updated`, and the
24
+ session-end telemetry path branches on `adapter.transport`: JSON-RPC
25
+ pulls the cumulative `tokenUsage.total` and maps the camelCase
26
+ `{input,output,cachedInput,reasoningOutput}Tokens` fields onto the
27
+ snake_case `actual.*_tokens` columns. PTY continues to scrape the
28
+ transcript tail. (#33)
29
+ - JSON-RPC adapter rejects `-m`/`--model`/`--model=…` early with a
30
+ clear `ArgumentError` pointing at `-c model="<name>"`. Previously the
31
+ flag was silently forwarded to `codex app-server`, which exited at
32
+ startup and surfaced only as an opaque `disconnected` transport
33
+ message. Legacy PTY adapter (`--legacy-pty`) still accepts `-m`.
34
+ `harnex help run` and `guides/01_dispatch.md` document the JSON-RPC
35
+ vs PTY flag-form difference. (#34)
36
+
5
37
  ## [0.6.4] — 2026-05-06
6
38
 
7
39
  ### Fixed
data/TECHNICAL.md CHANGED
@@ -28,6 +28,7 @@ harnex run codex -- --cd ~/other/repo
28
28
  | `--preset NAME` | Watch preset (`impl`, `plan`, `gate`), requires `--watch` |
29
29
  | `--watch-file PATH` | Auto-send a file-change hook (`--watch PATH`/`--watch=PATH` legacy) |
30
30
  | `--context TXT` | Give the agent a task on startup |
31
+ | `--auto-stop` | With `--context`, stop after the first task completion |
31
32
  | `--timeout SEC` | Wait budget for detached registration |
32
33
 
33
34
  ### `harnex send` — Talk to a running agent
@@ -63,10 +63,30 @@ harnex run codex --id cx-i-NN --tmux cx-i-NN \
63
63
  --context "Read and execute /tmp/task-impl-NN.md"
64
64
  ```
65
65
 
66
+ For one-shot context dispatches that should clean themselves up, add
67
+ `--auto-stop`. It requires `--context`, fires once on the first task
68
+ completion, and does not keep the session alive for later reuse. This keeps
69
+ parallel orchestration compact:
70
+
71
+ ```bash
72
+ for i in 1 2 3; do
73
+ harnex run codex --id w-$i --tmux w-$i --detach \
74
+ --context "Read and execute /tmp/task-$i.md" --auto-stop &
75
+ done
76
+ for i in 1 2 3; do harnex wait --id w-$i & done
77
+ wait
78
+ ```
79
+
66
80
  Rule: when you use `--tmux`, pass the same name as `--id`. If you pass only
67
81
  `--tmux NAME`, harnex creates a random session ID and the pane name no longer
68
82
  matches `harnex status` or `harnex pane --id`.
69
83
 
84
+ Codex flag forms differ between transports. The default JSON-RPC adapter
85
+ (`codex app-server`) does not accept `-m`/`--model`; pass the model as
86
+ `-c model="<name>"` instead. The legacy PTY adapter (`harnex run codex
87
+ --legacy-pty`) still accepts `-m`. Harnex rejects `-m` early on JSON-RPC
88
+ with an actionable error rather than letting the subprocess boot-disconnect.
89
+
70
90
  ## Send
71
91
 
72
92
  Use `--message` for short instructions and file references:
@@ -33,6 +33,8 @@ module Harnex
33
33
  ].freeze
34
34
 
35
35
  EVENTS = %w[task_complete turn_started item_completed disconnected].freeze
36
+ STOP_TERM_GRACE_SECONDS = 0.5
37
+ STOP_KILL_GRACE_SECONDS = 1.0
36
38
 
37
39
  # Server→client approval requests harnex auto-approves so dispatched
38
40
  # codex workers can run autonomously. Codex sends these via JSON-RPC
@@ -44,7 +46,7 @@ module Harnex
44
46
  APPROVAL_RESPONSES = {
45
47
  "applyPatchApproval" => { decision: "approved" },
46
48
  "execCommandApproval" => { decision: "approved" },
47
- "item/commandExecution/requestApproval" => { decision: "approved" },
49
+ "item/commandExecution/requestApproval" => { decision: "accept" },
48
50
  "item/fileChange/requestApproval" => { decision: "accept" }
49
51
  }.freeze
50
52
 
@@ -52,6 +54,7 @@ module Harnex
52
54
 
53
55
  def initialize(extra_args = [])
54
56
  super("codex", extra_args)
57
+ reject_unsupported_codex_flags!
55
58
  @initial_prompt = extract_initial_prompt(extra_args)
56
59
  @client = nil
57
60
  @thread_id = nil
@@ -165,7 +168,7 @@ module Harnex
165
168
  params[:effort] = effort if effort
166
169
 
167
170
  result = @client.request("turn/start", params)
168
- @current_turn_id = result["turnId"] || result["turn_id"] || result["id"]
171
+ @current_turn_id = result.dig("turn", "id")
169
172
  @state = :busy
170
173
  @current_turn_id
171
174
  end
@@ -194,6 +197,13 @@ module Harnex
194
197
  @state = :disconnected
195
198
  end
196
199
 
200
+ def terminate_subprocess(term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
201
+ @client&.terminate_process(
202
+ term_grace_seconds: term_grace_seconds,
203
+ kill_grace_seconds: kill_grace_seconds
204
+ )
205
+ end
206
+
197
207
  def pid
198
208
  @client&.pid
199
209
  end
@@ -215,7 +225,7 @@ module Harnex
215
225
  def extract_thread_id(payload)
216
226
  return nil unless payload.is_a?(Hash)
217
227
 
218
- payload.dig("thread", "id") || payload["threadId"] || payload["thread_id"]
228
+ payload.dig("thread", "id")
219
229
  end
220
230
 
221
231
  def extract_initial_prompt(extra_args)
@@ -227,6 +237,21 @@ module Harnex
227
237
  nil
228
238
  end
229
239
 
240
+ # `codex app-server` does not implement `-m/--model`; passing it
241
+ # causes the subprocess to exit at startup, surfacing only as a
242
+ # null-message transport disconnect. Same flag still works on the
243
+ # legacy PTY adapter (`harnex run codex --legacy-pty`).
244
+ def reject_unsupported_codex_flags!
245
+ bad = @extra_args.find do |a|
246
+ s = a.to_s
247
+ s == "-m" || s == "--model" || s.start_with?("--model=")
248
+ end
249
+ return unless bad
250
+
251
+ raise ArgumentError,
252
+ "-m/--model is not supported by `codex app-server`. Use `-c model=\"<name>\"` instead."
253
+ end
254
+
230
255
  # Codex CLI flags only — strips the harnex-context entry that
231
256
  # `--context` smuggles through @extra_args.
232
257
  def cli_extra_args
@@ -256,7 +281,7 @@ module Harnex
256
281
  when "thread/started"
257
282
  @thread_id ||= extract_thread_id(params)
258
283
  when "turn/started"
259
- @current_turn_id = params["turnId"] || params["turn_id"]
284
+ @current_turn_id = params.dig("turn", "id")
260
285
  @state = :busy
261
286
  when "turn/completed"
262
287
  @last_completed_at = Time.now
@@ -381,6 +406,26 @@ module Harnex
381
406
  @reader_thread&.join(2)
382
407
  end
383
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
+
384
429
  private
385
430
 
386
431
  def write_line(message)
@@ -479,6 +524,20 @@ module Harnex
479
524
  rescue Errno::ESRCH, Errno::EPERM
480
525
  false
481
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
482
541
  end
483
542
  end
484
543
  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
@@ -8,7 +8,7 @@ module Harnex
8
8
  KNOWN_FLAGS = %w[
9
9
  --id --description --detach --tmux --host --port --watch --watch-file
10
10
  --stall-after --max-resumes --preset --context --meta --summary-out
11
- --timeout --inbox-ttl --legacy-pty --help
11
+ --timeout --inbox-ttl --auto-stop --legacy-pty --help
12
12
  ].freeze
13
13
  VALUE_FLAGS = %w[
14
14
  --id --description --host --port --watch --watch-file --stall-after
@@ -32,6 +32,7 @@ module Harnex
32
32
  --preset NAME Watch preset: impl, plan, gate (requires --watch)
33
33
  --watch-file PATH Auto-send a file-change hook on modification
34
34
  --context TEXT Inject as the initial prompt (prepends session header)
35
+ --auto-stop Stop after the first task completion from --context
35
36
  --meta JSON Attach parsed JSON metadata to the started event
36
37
  --summary-out PATH Append dispatch telemetry summary JSONL to PATH
37
38
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
@@ -45,6 +46,7 @@ module Harnex
45
46
  Notes:
46
47
  Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
47
48
  Bare `--watch` enables the babysitter.
49
+ --auto-stop requires --context and fires once after the first completion.
48
50
  Explicit --stall-after/--max-resumes values override --preset defaults.
49
51
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
50
52
  Any other CLI name is launched with generic wrapping.
@@ -52,6 +54,7 @@ module Harnex
52
54
 
53
55
  Common patterns:
54
56
  #{program_name} codex --id cx-i-42 --tmux cx-i-42 --context "Read /tmp/task-impl-42.md"
57
+ #{program_name} codex --id cx-i-42 --tmux cx-i-42 --context "Read /tmp/task-impl-42.md" --auto-stop
55
58
  #{program_name} codex --id cx-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
56
59
  #{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
57
60
 
@@ -60,6 +63,8 @@ module Harnex
60
63
  Passing --tmux without --id creates a random harnex session ID.
61
64
  --watch is foreground-only; do not combine it with --tmux or --detach.
62
65
  Use -- before child CLI flags when a flag could be parsed by harnex.
66
+ Codex JSON-RPC: pass model as `-c model=NAME`, not `-m NAME`. The
67
+ legacy PTY adapter (--legacy-pty) accepts `-m`.
63
68
  TEXT
64
69
  end
65
70
 
@@ -80,6 +85,7 @@ module Harnex
80
85
  context: nil,
81
86
  meta: nil,
82
87
  summary_out: nil,
88
+ auto_stop: false,
83
89
  detach: false,
84
90
  tmux: false,
85
91
  tmux_name: nil,
@@ -98,6 +104,7 @@ module Harnex
98
104
  end
99
105
 
100
106
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
107
+ validate_auto_stop_context!
101
108
 
102
109
  repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
103
110
  @options[:summary_out] = resolve_summary_out(repo_root)
@@ -159,6 +166,7 @@ module Harnex
159
166
  tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
160
167
  tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
161
168
  tmux_cmd += ["--context", @options[:context]] if @options[:context]
169
+ tmux_cmd << "--auto-stop" if @options[:auto_stop]
162
170
  tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
163
171
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
164
172
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
@@ -266,7 +274,8 @@ module Harnex
266
274
  description: @options[:description],
267
275
  meta: @options[:meta],
268
276
  summary_out: @options[:summary_out],
269
- inbox_ttl: @options[:inbox_ttl]
277
+ inbox_ttl: @options[:inbox_ttl],
278
+ auto_stop: @options[:auto_stop]
270
279
  )
271
280
  end
272
281
 
@@ -412,6 +421,8 @@ module Harnex
412
421
  @options[:context] = required_option_value(arg, argv[index])
413
422
  when /\A--context=(.+)\z/
414
423
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
424
+ when "--auto-stop"
425
+ @options[:auto_stop] = true
415
426
  when "--meta"
416
427
  index += 1
417
428
  @options[:meta] = parse_meta(required_option_value(arg, argv[index]))
@@ -473,7 +484,7 @@ module Harnex
473
484
  case arg
474
485
  when "--"
475
486
  return false
476
- when "-h", "--help", "--detach", "--tmux", "--legacy-pty"
487
+ when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--legacy-pty"
477
488
  nil
478
489
  when /\A--tmux=/
479
490
  nil
@@ -520,6 +531,13 @@ module Harnex
520
531
  @options[:max_resumes] = preset[:max_resumes] unless @options[:max_resumes_explicit]
521
532
  end
522
533
 
534
+ def validate_auto_stop_context!
535
+ return unless @options[:auto_stop]
536
+ return if @options[:context]
537
+
538
+ raise OptionParser::InvalidOption, "harnex run: --auto-stop requires --context"
539
+ end
540
+
523
541
  def parse_non_negative_integer(value, option_name:)
524
542
  integer = Integer(value)
525
543
  raise OptionParser::InvalidArgument, "#{option_name} must be 0 or greater" if integer.negative?
@@ -39,7 +39,7 @@ module Harnex
39
39
 
40
40
  attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path
41
41
 
42
- def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL)
42
+ def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false)
43
43
  @adapter = adapter
44
44
  @command = command
45
45
  @repo_root = repo_root
@@ -60,6 +60,8 @@ module Harnex
60
60
  @mutex = Mutex.new
61
61
  @inject_mutex = Mutex.new
62
62
  @events_mutex = Mutex.new
63
+ @stop_mutex = Mutex.new
64
+ @auto_stop_mutex = Mutex.new
63
65
  @injected_count = 0
64
66
  @last_injected_at = nil
65
67
  @started_at = Time.now
@@ -74,8 +76,14 @@ module Harnex
74
76
  @usage_summary = {}
75
77
  @ended_at = nil
76
78
  @exit_reason = nil
79
+ @last_error = nil
80
+ @session_finalized = false
77
81
  @turn_started_seen = false
78
82
  @last_completed_at = nil
83
+ @auto_stop = !!auto_stop
84
+ @auto_stop_fired = false
85
+ @auto_stop_seen_busy = false
86
+ @stop_requested = false
79
87
  @writer = nil
80
88
  @pid = nil
81
89
  @term_signal = nil
@@ -116,6 +124,7 @@ module Harnex
116
124
  def run_pty
117
125
  @reader, @writer, @pid = PTY.spawn(child_env, *command)
118
126
  @writer.sync = true
127
+ arm_auto_stop_after_initial_context
119
128
  emit_started_event
120
129
  emit_git_start_event
121
130
 
@@ -137,16 +146,12 @@ module Harnex
137
146
  @ended_at = Time.now
138
147
 
139
148
  output_thread.join(1)
140
- emit_session_end_telemetry
141
- @exit_reason = classify_exit
142
- summary_record = build_summary_record
143
- append_summary_record(summary_record)
144
- emit_summary_event
145
- emit_exit_event
149
+ finalize_session!
146
150
  input_thread&.kill
147
151
  watch_thread&.kill
148
152
  @exit_code
149
153
  ensure
154
+ finalize_session!
150
155
  @inbox.stop
151
156
  STDIN.cooked! if STDIN.tty? && stdin_state
152
157
  @server&.stop
@@ -205,21 +210,34 @@ module Harnex
205
210
  inject_sequence([{ text: text, newline: newline }])
206
211
  end
207
212
 
208
- def inject_stop
213
+ def inject_stop(turn_id: nil)
214
+ unless adapter.transport == :stdio_jsonrpc
215
+ raise "session is not running" unless pid && Harnex.alive_pid?(pid)
216
+ end
217
+
218
+ return { ok: true, signal: "already_requested" } if stop_requested!
219
+
209
220
  if adapter.transport == :stdio_jsonrpc
210
221
  @inject_mutex.synchronize do
211
222
  begin
212
- adapter.interrupt
223
+ adapter.interrupt(turn_id: turn_id)
213
224
  rescue StandardError
214
225
  nil
215
226
  end
216
227
  @state_machine.force_busy!
217
228
  end
229
+ if adapter.respond_to?(:terminate_subprocess)
230
+ Thread.new do
231
+ begin
232
+ adapter.terminate_subprocess
233
+ rescue Errno::ESRCH, StandardError
234
+ nil
235
+ end
236
+ end
237
+ end
218
238
  return { ok: true, signal: "interrupt_sent" }
219
239
  end
220
240
 
221
- raise "session is not running" unless pid && Harnex.alive_pid?(pid)
222
-
223
241
  @inject_mutex.synchronize do
224
242
  adapter.inject_exit(@writer)
225
243
  @state_machine.force_busy!
@@ -341,15 +359,11 @@ module Harnex
341
359
  end
342
360
  @ended_at = Time.now
343
361
 
344
- emit_session_end_telemetry
345
- @exit_reason = classify_exit
346
- summary_record = build_summary_record
347
- append_summary_record(summary_record)
348
- emit_summary_event
349
- emit_exit_event
362
+ finalize_session!
350
363
  watch_thread&.kill
351
364
  @exit_code
352
365
  ensure
366
+ finalize_session!
353
367
  @inbox.stop
354
368
  @server&.stop
355
369
  begin
@@ -376,18 +390,20 @@ module Harnex
376
390
 
377
391
  case method
378
392
  when "thread/started"
379
- @rpc_thread_id = params["threadId"] || params["thread_id"]
393
+ @rpc_thread_id = params.dig("thread", "id")
380
394
  when "turn/started"
381
395
  @turn_started_seen = true
382
396
  @state_machine.force_busy!
383
- emit_event("turn_started", turnId: params["turnId"] || params["turn_id"])
397
+ emit_event("turn_started", turnId: params.dig("turn", "id"))
384
398
  when "turn/completed"
385
399
  @last_completed_at = Time.now
386
400
  @state_machine.force_prompt!
387
- payload = { turnId: params["turnId"] || params["turn_id"] }
388
- payload[:status] = params["status"] if params["status"]
401
+ turn = params["turn"] || {}
402
+ payload = { turnId: turn["id"] }
403
+ payload[:status] = turn["status"] if turn["status"]
389
404
  payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"]
390
405
  emit_event("task_complete", **payload)
406
+ schedule_auto_stop("task_complete", turn_id: payload[:turnId])
391
407
  when "item/completed"
392
408
  emit_event("item_completed", item: params["item"])
393
409
  text = render_item_text(params["item"])
@@ -395,14 +411,18 @@ module Harnex
395
411
  when "thread/compacted"
396
412
  emit_event("compaction", **params)
397
413
  when "thread/tokenUsage/updated"
398
- # Surfaced via status fields in Phase 4; no event spam.
399
- @token_usage = params["usage"] || params
414
+ # Schema: ThreadTokenUsageUpdatedNotification carries
415
+ # `tokenUsage: { last, total, modelContextWindow? }` where each
416
+ # breakdown has camelCase {input,output,cachedInput,reasoningOutput,total}Tokens.
417
+ # Snapshot it; the cumulative `total` is read at session end.
418
+ @token_usage = params["tokenUsage"] if params["tokenUsage"].is_a?(Hash)
400
419
  when "thread/status/changed"
401
420
  # State machine reflects RPC state; no event needed.
402
421
  nil
403
422
  when "account/rateLimits/updated"
404
423
  @rate_limits = params
405
424
  when "error"
425
+ @last_error = params["message"].to_s unless params["message"].to_s.empty?
406
426
  @state_machine.force_busy!
407
427
  emit_event("disconnected", source: "error_notification", message: params["message"])
408
428
  signal_rpc_done!
@@ -413,6 +433,7 @@ module Harnex
413
433
 
414
434
  def handle_rpc_disconnect(error)
415
435
  msg = error.is_a?(Hash) ? error["message"] : error&.message
436
+ @last_error = msg.to_s unless msg.to_s.empty?
416
437
  @state_machine.force_busy!
417
438
  emit_event("disconnected", source: "transport", message: msg) rescue nil
418
439
  signal_rpc_done!
@@ -430,14 +451,15 @@ module Harnex
430
451
  def render_item_text(item)
431
452
  return nil unless item.is_a?(Hash)
432
453
 
433
- type = item["type"] || item["kind"]
434
- case type
435
- when "agent_message", "assistant_message"
436
- item["text"] || item.dig("message", "text")
437
- when "tool_call"
438
- name = item["name"] || item.dig("tool", "name") || "tool"
439
- params = item["params"] || item["arguments"]
440
- "tool: #{name}#{params ? " #{summarize(params)}" : ""}"
454
+ case item["type"]
455
+ when "agentMessage"
456
+ item["text"]
457
+ when "mcpToolCall", "dynamicToolCall"
458
+ name = item["tool"] || "tool"
459
+ args = item["arguments"]
460
+ "tool: #{name}#{args ? " #{summarize(args)}" : ""}"
461
+ when "commandExecution"
462
+ "command: #{item["command"]}"
441
463
  else
442
464
  item["text"]
443
465
  end
@@ -644,7 +666,9 @@ module Harnex
644
666
  @output_buffer = @output_buffer.byteslice(overflow, OUTPUT_BUFFER_LIMIT) if overflow.positive?
645
667
  @output_buffer.dup
646
668
  end
647
- @state_machine.update(snapshot)
669
+ old_state = @state_machine.to_s.to_sym
670
+ new_state = @state_machine.update(snapshot)
671
+ handle_auto_stop_pty_transition(old_state, new_state)
648
672
  end
649
673
 
650
674
  def append_output_log(chunk)
@@ -679,7 +703,7 @@ module Harnex
679
703
  end
680
704
 
681
705
  def emit_session_end_telemetry
682
- @usage_summary = normalized_usage_summary(adapter.parse_session_summary(transcript_tail))
706
+ @usage_summary = normalized_usage_summary(collect_session_summary)
683
707
  emit_event("usage", **@usage_summary)
684
708
 
685
709
  @git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
@@ -707,6 +731,73 @@ module Harnex
707
731
  emit_event("exited", **payload)
708
732
  end
709
733
 
734
+ def finalize_session!
735
+ return if @session_finalized
736
+ return unless @events_log
737
+
738
+ @session_finalized = true
739
+ @ended_at ||= Time.now
740
+ begin
741
+ emit_session_end_telemetry
742
+ rescue StandardError => e
743
+ @usage_summary = normalized_usage_summary(nil)
744
+ warn("harnex: failed to collect session-end telemetry: #{e.message}")
745
+ end
746
+ @exit_reason ||= classify_exit
747
+ append_summary_record(build_summary_record)
748
+ emit_summary_event
749
+ emit_exit_event
750
+ end
751
+
752
+ def stop_requested!
753
+ @stop_mutex.synchronize do
754
+ return true if @stop_requested
755
+
756
+ @stop_requested = true
757
+ false
758
+ end
759
+ end
760
+
761
+ def arm_auto_stop_after_initial_context
762
+ return unless @auto_stop
763
+ return unless adapter.transport == :pty
764
+
765
+ @auto_stop_mutex.synchronize { @auto_stop_seen_busy = true }
766
+ @state_machine.force_busy!
767
+ end
768
+
769
+ def handle_auto_stop_pty_transition(old_state, new_state)
770
+ return unless @auto_stop
771
+ return unless adapter.transport == :pty
772
+
773
+ seen_busy = @auto_stop_mutex.synchronize do
774
+ @auto_stop_seen_busy ||= old_state == :busy || new_state == :busy
775
+ end
776
+ schedule_auto_stop("prompt_after_busy") if seen_busy && new_state == :prompt
777
+ end
778
+
779
+ def schedule_auto_stop(reason, turn_id: nil)
780
+ return unless @auto_stop
781
+
782
+ should_fire = @auto_stop_mutex.synchronize do
783
+ if @auto_stop_fired
784
+ false
785
+ else
786
+ @auto_stop_fired = true
787
+ true
788
+ end
789
+ end
790
+ return unless should_fire
791
+
792
+ Thread.new do
793
+ begin
794
+ inject_stop(turn_id: turn_id)
795
+ rescue StandardError => e
796
+ warn("harnex: auto-stop failed after #{reason}: #{e.message}")
797
+ end
798
+ end
799
+ end
800
+
710
801
  def classify_exit
711
802
  return "timeout" if @exit_code == 124
712
803
  return "success" if @exit_code == 0 && session_summary_present?
@@ -776,7 +867,7 @@ module Harnex
776
867
  counters[:disconnections] = [counters[:disconnections], 1].max
777
868
  end
778
869
 
779
- {
870
+ actual = {
780
871
  model: meta_hash["model"],
781
872
  effort: meta_hash["effort"],
782
873
  duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
@@ -798,6 +889,8 @@ module Harnex
798
889
  tests_passed: nil,
799
890
  tests_failed: nil
800
891
  }
892
+ actual[:last_error] = @last_error if @exit_reason == "boot_failure" && @last_error
893
+ actual
801
894
  end
802
895
 
803
896
  def summary_predicted_payload
@@ -826,6 +919,33 @@ module Harnex
826
919
  USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
827
920
  end
828
921
 
922
+ # Adapters speaking JSON-RPC capture token usage from the structured
923
+ # `thread/tokenUsage/updated` notification stream and don't have a
924
+ # transcript to scrape; fall back to the schema-true cumulative
925
+ # `total` block. Other adapters parse the transcript tail.
926
+ def collect_session_summary
927
+ if adapter.transport == :stdio_jsonrpc
928
+ summary_from_token_usage
929
+ else
930
+ adapter.parse_session_summary(transcript_tail)
931
+ end
932
+ end
933
+
934
+ def summary_from_token_usage
935
+ return {} unless @token_usage.is_a?(Hash)
936
+
937
+ total = @token_usage["total"]
938
+ return {} unless total.is_a?(Hash)
939
+
940
+ {
941
+ input_tokens: total["inputTokens"],
942
+ output_tokens: total["outputTokens"],
943
+ reasoning_tokens: total["reasoningOutputTokens"],
944
+ cached_tokens: total["cachedInputTokens"],
945
+ total_tokens: total["totalTokens"]
946
+ }
947
+ end
948
+
829
949
  def transcript_tail
830
950
  return "" unless File.file?(output_log_path)
831
951
 
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.6.4"
3
- RELEASE_DATE = "2026-05-06"
2
+ VERSION = "0.6.5"
3
+ RELEASE_DATE = "2026-05-07"
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.4
4
+ version: 0.6.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-06 00:00:00.000000000 Z
11
+ date: 2026-05-07 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
14
14
  adds a control plane for discovery, messaging, and coordination.