harnex 0.6.3 → 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: f547957cf6589b1d153e4cf7e462482902ea71e7e1291281524e867596ea4730
4
- data.tar.gz: 4f01e84be1c31692f2b68fa09850dd781116398ab45f3e6f0b2ad76c7f52846c
3
+ metadata.gz: 297484ce749d268b7582afd226d617c79fed80a12928ced5daa8df2c3bd4f763
4
+ data.tar.gz: 554528411e6bca5c6037ca579067f37b4b58626b88ed5c263fe95debe4517f70
5
5
  SHA512:
6
- metadata.gz: eb7cd143d32da68e674bbc620d6ac6c56cb4388009e5f074ab54958288e6a02a0a31df38dae9a1713881ce0ea6315f2f8c01147a1b9b071056439bc8e3734518
7
- data.tar.gz: a3c17557910b63e987cad3497c54599044417484710b8cd7cc2f152bfb9aa43b6231ba99c11efc93c0c9788bdc7f41077d22dda30e4a13b6be724f109e0eabbf
6
+ metadata.gz: 1aa029fe0d4177ef1a9043c863935655df9b636b48bedaad3f84d35e6d02d0b9255b16ce2c08a5220f2eb4b89930fe0002b2926c47039b7cb1eeab2a6e333b07
7
+ data.tar.gz: ab908f0f299a9a0ae286cf629202fa26d518f59658518515818c388f21daadbd42a4a839417878a99c3f863439df5d11dd51cdf7c3675b737638293b5c739832
data/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
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
+
37
+ ## [0.6.4] — 2026-05-06
38
+
39
+ ### Fixed
40
+
41
+ - JSON-RPC adapter (`codex app-server`): harnex now mediates Codex's
42
+ server-to-client approval requests via the protocol — auto-approves
43
+ `applyPatchApproval`, `execCommandApproval`,
44
+ `item/commandExecution/requestApproval`, and
45
+ `item/fileChange/requestApproval`. Previously the adapter rejected
46
+ every server-side request with `-32601 "Unsupported server request"`,
47
+ which meant Codex's default sandbox blocked shell exec, file changes,
48
+ git commits, and package-manager invocations whenever a dispatched
49
+ worker tried to do real work. Autonomous worker dispatches now run
50
+ cleanly under the default sandbox without needing
51
+ `--dangerously-bypass-approvals-and-sandbox` or
52
+ `-c sandbox_mode=danger-full-access`.
53
+ - `CodexAppServer#build_command` now appends operator-supplied codex
54
+ flags (passed via `harnex run codex -- -c key=value`) while still
55
+ filtering out the harnex-context entry that `--context` smuggles
56
+ through `@extra_args` (codex `app-server` rejects positional input).
57
+
58
+ ### Changed
59
+
60
+ - `--legacy-pty` is now a long-term supported fallback rather than a
61
+ deprecated path. The 0.7.0-removal plan is dropped — the legacy PTY
62
+ adapter remains the right tool for interactive/TUI use cases and for
63
+ any operator who prefers terminal-native Codex chrome. JSON-RPC
64
+ remains the default for autonomous worker dispatches and structured
65
+ observability.
66
+
67
+ ### Added
68
+
69
+ - JSON-RPC adapter: classify sub-5s pre-turn exits as `boot_failure`
70
+ (vs the existing `disconnected` terminal state), tracked by latching
71
+ on `turn/started`. `build_summary_actual` counts `boot_failure` exits
72
+ in `actual.disconnections` so early-boot deaths are not lost from
73
+ dispatch telemetry. First of three planned commits for issue #32;
74
+ remaining work (ensure-block telemetry write + optional `last_error`
75
+ capture) tracked separately.
76
+
3
77
  ## [0.6.3] — 2026-05-06
4
78
 
5
79
  ### 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
@@ -330,6 +331,16 @@ The adapter reads the screen and returns a state hash:
330
331
  - Notifications (`turn/started`, `turn/completed`, `item/completed`,
331
332
  `error`, `thread/compacted`, …) fan into the events log.
332
333
  `task_complete` is the harnex-side event for `turn/completed`.
334
+ - **Approval mediation**: Codex's app-server protocol delegates
335
+ sandbox/approval decisions to the JSON-RPC client via server-to-
336
+ client requests. Harnex auto-approves `applyPatchApproval`,
337
+ `execCommandApproval`, `item/commandExecution/requestApproval`, and
338
+ `item/fileChange/requestApproval`, so autonomous workers can run
339
+ shell commands, write files, commit, and invoke package managers
340
+ under Codex's default sandbox without any bypass flag. Other
341
+ server-to-client requests (permissions, user-input, dynamic-tool,
342
+ auth-refresh) currently fall through to `-32601` until use cases
343
+ appear.
333
344
  - Disconnect is detected from JSON-RPC error responses, subprocess
334
345
  EOF, parse errors, or a server `error` notification — no screen
335
346
  regex required.
@@ -339,7 +350,7 @@ The adapter reads the screen and returns a state hash:
339
350
  - See `docs/codex-appserver.md` for the full mapping table and
340
351
  troubleshooting.
341
352
 
342
- #### Codex Adapter (legacy PTY — `--legacy-pty`, removal in 0.7.0)
353
+ #### Codex Adapter (legacy PTY — `--legacy-pty`, long-term fallback)
343
354
 
344
355
  - Launches with `--no-alt-screen` for inline screen output
345
356
  - Detects prompt by looking for `›` prefix in recent lines
@@ -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,11 +33,28 @@ 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
38
+
39
+ # Server→client approval requests harnex auto-approves so dispatched
40
+ # codex workers can run autonomously. Codex sends these via JSON-RPC
41
+ # when its sandbox/approval policy needs a client decision; without
42
+ # a handler the client returns -32601 and codex blocks the operation.
43
+ # Permissions / user-input / dynamic-tool / auth-refresh requests
44
+ # have richer response shapes and are deliberately not auto-handled
45
+ # — they fall through to -32601 until a use case appears.
46
+ APPROVAL_RESPONSES = {
47
+ "applyPatchApproval" => { decision: "approved" },
48
+ "execCommandApproval" => { decision: "approved" },
49
+ "item/commandExecution/requestApproval" => { decision: "accept" },
50
+ "item/fileChange/requestApproval" => { decision: "accept" }
51
+ }.freeze
36
52
 
37
53
  attr_reader :thread_id, :current_turn_id, :last_completed_at, :initial_prompt
38
54
 
39
55
  def initialize(extra_args = [])
40
56
  super("codex", extra_args)
57
+ reject_unsupported_codex_flags!
41
58
  @initial_prompt = extract_initial_prompt(extra_args)
42
59
  @client = nil
43
60
  @thread_id = nil
@@ -56,8 +73,13 @@ module Harnex
56
73
  ["codex", "app-server"]
57
74
  end
58
75
 
76
+ # The harnex-context entry (set by `--context`) is delivered via
77
+ # JSON-RPC `turn/start`, not as a CLI argument — codex app-server
78
+ # rejects positional input and would exit immediately. Operator-
79
+ # supplied codex flags (passed via `harnex run codex -- ...`) are
80
+ # appended so e.g. `-c sandbox_mode=danger-full-access` works.
59
81
  def build_command
60
- base_command
82
+ base_command + cli_extra_args
61
83
  end
62
84
 
63
85
  def describe
@@ -120,6 +142,7 @@ module Harnex
120
142
  end
121
143
 
122
144
  @client.on_notification { |msg| handle_notification(msg) }
145
+ @client.on_request { |method, params| handle_server_request(method, params) }
123
146
  @client.on_disconnect { |err| handle_disconnect(err) }
124
147
  @client.start
125
148
  perform_handshake
@@ -127,6 +150,13 @@ module Harnex
127
150
  self
128
151
  end
129
152
 
153
+ # Auto-approve known approval-style requests so dispatched workers
154
+ # can run without a human-in-the-loop. Returns the response body to
155
+ # serialize as JSON-RPC `result`, or `nil` to fall through to -32601.
156
+ def handle_server_request(method, _params)
157
+ APPROVAL_RESPONSES[method]
158
+ end
159
+
130
160
  def dispatch(prompt:, model: nil, effort: nil)
131
161
  ensure_open!
132
162
  ensure_thread!
@@ -138,7 +168,7 @@ module Harnex
138
168
  params[:effort] = effort if effort
139
169
 
140
170
  result = @client.request("turn/start", params)
141
- @current_turn_id = result["turnId"] || result["turn_id"] || result["id"]
171
+ @current_turn_id = result.dig("turn", "id")
142
172
  @state = :busy
143
173
  @current_turn_id
144
174
  end
@@ -167,6 +197,13 @@ module Harnex
167
197
  @state = :disconnected
168
198
  end
169
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
+
170
207
  def pid
171
208
  @client&.pid
172
209
  end
@@ -188,7 +225,7 @@ module Harnex
188
225
  def extract_thread_id(payload)
189
226
  return nil unless payload.is_a?(Hash)
190
227
 
191
- payload.dig("thread", "id") || payload["threadId"] || payload["thread_id"]
228
+ payload.dig("thread", "id")
192
229
  end
193
230
 
194
231
  def extract_initial_prompt(extra_args)
@@ -200,6 +237,27 @@ module Harnex
200
237
  nil
201
238
  end
202
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
+
255
+ # Codex CLI flags only — strips the harnex-context entry that
256
+ # `--context` smuggles through @extra_args.
257
+ def cli_extra_args
258
+ @extra_args.reject { |a| a.is_a?(String) && a.start_with?("[harnex session id=") }
259
+ end
260
+
203
261
  def perform_handshake
204
262
  @client.request("initialize", {
205
263
  clientInfo: {
@@ -223,7 +281,7 @@ module Harnex
223
281
  when "thread/started"
224
282
  @thread_id ||= extract_thread_id(params)
225
283
  when "turn/started"
226
- @current_turn_id = params["turnId"] || params["turn_id"]
284
+ @current_turn_id = params.dig("turn", "id")
227
285
  @state = :busy
228
286
  when "turn/completed"
229
287
  @last_completed_at = Time.now
@@ -270,6 +328,7 @@ module Harnex
270
328
  @id_mutex = Mutex.new
271
329
  @write_mutex = Mutex.new
272
330
  @notification_handler = nil
331
+ @request_handler = nil
273
332
  @disconnect_handler = nil
274
333
  @disconnect_signaled = false
275
334
  @closed = false
@@ -280,6 +339,13 @@ module Harnex
280
339
  @notification_handler = block
281
340
  end
282
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
+
283
349
  def on_disconnect(&block)
284
350
  @disconnect_handler = block
285
351
  end
@@ -340,6 +406,26 @@ module Harnex
340
406
  @reader_thread&.join(2)
341
407
  end
342
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
+
343
429
  private
344
430
 
345
431
  def write_line(message)
@@ -381,11 +467,7 @@ module Harnex
381
467
 
382
468
  def dispatch_message(message)
383
469
  if message["id"] && message["method"]
384
- write_line({
385
- jsonrpc: "2.0",
386
- id: message["id"],
387
- error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
388
- })
470
+ handle_server_request(message)
389
471
  return
390
472
  end
391
473
 
@@ -406,6 +488,29 @@ module Harnex
406
488
  @notification_handler&.call(message) if message["method"]
407
489
  end
408
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
+
409
514
  def signal_disconnect(error)
410
515
  return if @disconnect_signaled
411
516
 
@@ -419,6 +524,20 @@ module Harnex
419
524
  rescue Errno::ESRCH, Errno::EPERM
420
525
  false
421
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
422
541
  end
423
542
  end
424
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
@@ -18,7 +18,8 @@ module Harnex
18
18
 
19
19
  # Phase 3 flipped the default — `codex` resolves to CodexAppServer.
20
20
  # Legacy PTY adapter is reachable via `legacy_pty: true` (driven by
21
- # `harnex run codex --legacy-pty`). Will be removed in 0.7.0.
21
+ # `harnex run codex --legacy-pty`); kept as a long-term supported
22
+ # fallback for interactive/TUI use cases.
22
23
  def codex_appserver_enabled?
23
24
  true
24
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,18 +32,21 @@ 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})
38
39
  --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
39
40
  --legacy-pty (codex only) Use the legacy PTY adapter instead of
40
- the JSON-RPC `app-server` adapter. Deprecated; will
41
- be removed in 0.7.0.
41
+ the JSON-RPC `app-server` adapter. Long-term
42
+ supported fallback for interactive/TUI use; JSON-RPC
43
+ remains the default for autonomous dispatches.
42
44
  -h, --help Show this help
43
45
 
44
46
  Notes:
45
47
  Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
46
48
  Bare `--watch` enables the babysitter.
49
+ --auto-stop requires --context and fires once after the first completion.
47
50
  Explicit --stall-after/--max-resumes values override --preset defaults.
48
51
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
49
52
  Any other CLI name is launched with generic wrapping.
@@ -51,6 +54,7 @@ module Harnex
51
54
 
52
55
  Common patterns:
53
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
54
58
  #{program_name} codex --id cx-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
55
59
  #{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
56
60
 
@@ -59,6 +63,8 @@ module Harnex
59
63
  Passing --tmux without --id creates a random harnex session ID.
60
64
  --watch is foreground-only; do not combine it with --tmux or --detach.
61
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`.
62
68
  TEXT
63
69
  end
64
70
 
@@ -79,6 +85,7 @@ module Harnex
79
85
  context: nil,
80
86
  meta: nil,
81
87
  summary_out: nil,
88
+ auto_stop: false,
82
89
  detach: false,
83
90
  tmux: false,
84
91
  tmux_name: nil,
@@ -97,6 +104,7 @@ module Harnex
97
104
  end
98
105
 
99
106
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
107
+ validate_auto_stop_context!
100
108
 
101
109
  repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
102
110
  @options[:summary_out] = resolve_summary_out(repo_root)
@@ -158,6 +166,7 @@ module Harnex
158
166
  tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
159
167
  tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
160
168
  tmux_cmd += ["--context", @options[:context]] if @options[:context]
169
+ tmux_cmd << "--auto-stop" if @options[:auto_stop]
161
170
  tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
162
171
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
163
172
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
@@ -265,7 +274,8 @@ module Harnex
265
274
  description: @options[:description],
266
275
  meta: @options[:meta],
267
276
  summary_out: @options[:summary_out],
268
- inbox_ttl: @options[:inbox_ttl]
277
+ inbox_ttl: @options[:inbox_ttl],
278
+ auto_stop: @options[:auto_stop]
269
279
  )
270
280
  end
271
281
 
@@ -411,6 +421,8 @@ module Harnex
411
421
  @options[:context] = required_option_value(arg, argv[index])
412
422
  when /\A--context=(.+)\z/
413
423
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
424
+ when "--auto-stop"
425
+ @options[:auto_stop] = true
414
426
  when "--meta"
415
427
  index += 1
416
428
  @options[:meta] = parse_meta(required_option_value(arg, argv[index]))
@@ -472,7 +484,7 @@ module Harnex
472
484
  case arg
473
485
  when "--"
474
486
  return false
475
- when "-h", "--help", "--detach", "--tmux", "--legacy-pty"
487
+ when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--legacy-pty"
476
488
  nil
477
489
  when /\A--tmux=/
478
490
  nil
@@ -519,6 +531,13 @@ module Harnex
519
531
  @options[:max_resumes] = preset[:max_resumes] unless @options[:max_resumes_explicit]
520
532
  end
521
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
+
522
541
  def parse_non_negative_integer(value, option_name:)
523
542
  integer = Integer(value)
524
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,7 +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
81
+ @turn_started_seen = false
77
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
78
87
  @writer = nil
79
88
  @pid = nil
80
89
  @term_signal = nil
@@ -115,6 +124,7 @@ module Harnex
115
124
  def run_pty
116
125
  @reader, @writer, @pid = PTY.spawn(child_env, *command)
117
126
  @writer.sync = true
127
+ arm_auto_stop_after_initial_context
118
128
  emit_started_event
119
129
  emit_git_start_event
120
130
 
@@ -136,16 +146,12 @@ module Harnex
136
146
  @ended_at = Time.now
137
147
 
138
148
  output_thread.join(1)
139
- emit_session_end_telemetry
140
- @exit_reason = classify_exit
141
- summary_record = build_summary_record
142
- append_summary_record(summary_record)
143
- emit_summary_event
144
- emit_exit_event
149
+ finalize_session!
145
150
  input_thread&.kill
146
151
  watch_thread&.kill
147
152
  @exit_code
148
153
  ensure
154
+ finalize_session!
149
155
  @inbox.stop
150
156
  STDIN.cooked! if STDIN.tty? && stdin_state
151
157
  @server&.stop
@@ -204,21 +210,34 @@ module Harnex
204
210
  inject_sequence([{ text: text, newline: newline }])
205
211
  end
206
212
 
207
- 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
+
208
220
  if adapter.transport == :stdio_jsonrpc
209
221
  @inject_mutex.synchronize do
210
222
  begin
211
- adapter.interrupt
223
+ adapter.interrupt(turn_id: turn_id)
212
224
  rescue StandardError
213
225
  nil
214
226
  end
215
227
  @state_machine.force_busy!
216
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
217
238
  return { ok: true, signal: "interrupt_sent" }
218
239
  end
219
240
 
220
- raise "session is not running" unless pid && Harnex.alive_pid?(pid)
221
-
222
241
  @inject_mutex.synchronize do
223
242
  adapter.inject_exit(@writer)
224
243
  @state_machine.force_busy!
@@ -340,15 +359,11 @@ module Harnex
340
359
  end
341
360
  @ended_at = Time.now
342
361
 
343
- emit_session_end_telemetry
344
- @exit_reason = classify_exit
345
- summary_record = build_summary_record
346
- append_summary_record(summary_record)
347
- emit_summary_event
348
- emit_exit_event
362
+ finalize_session!
349
363
  watch_thread&.kill
350
364
  @exit_code
351
365
  ensure
366
+ finalize_session!
352
367
  @inbox.stop
353
368
  @server&.stop
354
369
  begin
@@ -375,17 +390,20 @@ module Harnex
375
390
 
376
391
  case method
377
392
  when "thread/started"
378
- @rpc_thread_id = params["threadId"] || params["thread_id"]
393
+ @rpc_thread_id = params.dig("thread", "id")
379
394
  when "turn/started"
395
+ @turn_started_seen = true
380
396
  @state_machine.force_busy!
381
- emit_event("turn_started", turnId: params["turnId"] || params["turn_id"])
397
+ emit_event("turn_started", turnId: params.dig("turn", "id"))
382
398
  when "turn/completed"
383
399
  @last_completed_at = Time.now
384
400
  @state_machine.force_prompt!
385
- payload = { turnId: params["turnId"] || params["turn_id"] }
386
- payload[:status] = params["status"] if params["status"]
401
+ turn = params["turn"] || {}
402
+ payload = { turnId: turn["id"] }
403
+ payload[:status] = turn["status"] if turn["status"]
387
404
  payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"]
388
405
  emit_event("task_complete", **payload)
406
+ schedule_auto_stop("task_complete", turn_id: payload[:turnId])
389
407
  when "item/completed"
390
408
  emit_event("item_completed", item: params["item"])
391
409
  text = render_item_text(params["item"])
@@ -393,14 +411,18 @@ module Harnex
393
411
  when "thread/compacted"
394
412
  emit_event("compaction", **params)
395
413
  when "thread/tokenUsage/updated"
396
- # Surfaced via status fields in Phase 4; no event spam.
397
- @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)
398
419
  when "thread/status/changed"
399
420
  # State machine reflects RPC state; no event needed.
400
421
  nil
401
422
  when "account/rateLimits/updated"
402
423
  @rate_limits = params
403
424
  when "error"
425
+ @last_error = params["message"].to_s unless params["message"].to_s.empty?
404
426
  @state_machine.force_busy!
405
427
  emit_event("disconnected", source: "error_notification", message: params["message"])
406
428
  signal_rpc_done!
@@ -411,6 +433,7 @@ module Harnex
411
433
 
412
434
  def handle_rpc_disconnect(error)
413
435
  msg = error.is_a?(Hash) ? error["message"] : error&.message
436
+ @last_error = msg.to_s unless msg.to_s.empty?
414
437
  @state_machine.force_busy!
415
438
  emit_event("disconnected", source: "transport", message: msg) rescue nil
416
439
  signal_rpc_done!
@@ -428,14 +451,15 @@ module Harnex
428
451
  def render_item_text(item)
429
452
  return nil unless item.is_a?(Hash)
430
453
 
431
- type = item["type"] || item["kind"]
432
- case type
433
- when "agent_message", "assistant_message"
434
- item["text"] || item.dig("message", "text")
435
- when "tool_call"
436
- name = item["name"] || item.dig("tool", "name") || "tool"
437
- params = item["params"] || item["arguments"]
438
- "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"]}"
439
463
  else
440
464
  item["text"]
441
465
  end
@@ -642,7 +666,9 @@ module Harnex
642
666
  @output_buffer = @output_buffer.byteslice(overflow, OUTPUT_BUFFER_LIMIT) if overflow.positive?
643
667
  @output_buffer.dup
644
668
  end
645
- @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)
646
672
  end
647
673
 
648
674
  def append_output_log(chunk)
@@ -677,7 +703,7 @@ module Harnex
677
703
  end
678
704
 
679
705
  def emit_session_end_telemetry
680
- @usage_summary = normalized_usage_summary(adapter.parse_session_summary(transcript_tail))
706
+ @usage_summary = normalized_usage_summary(collect_session_summary)
681
707
  emit_event("usage", **@usage_summary)
682
708
 
683
709
  @git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
@@ -705,14 +731,90 @@ module Harnex
705
731
  emit_event("exited", **payload)
706
732
  end
707
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
+
708
801
  def classify_exit
709
802
  return "timeout" if @exit_code == 124
803
+ return "success" if @exit_code == 0 && session_summary_present?
804
+ return "boot_failure" if boot_failure_exit?
710
805
  return "failure" unless @exit_code == 0
711
- return "success" if session_summary_present?
712
806
 
713
807
  "disconnected"
714
808
  end
715
809
 
810
+ def boot_failure_exit?
811
+ return false unless adapter.transport == :stdio_jsonrpc
812
+ return false if @turn_started_seen
813
+
814
+ lifetime = (@ended_at || Time.now) - @started_at
815
+ lifetime <= 5
816
+ end
817
+
716
818
  def session_summary_present?
717
819
  @usage_summary.values.any? { |value| !value.nil? }
718
820
  end
@@ -761,9 +863,11 @@ module Harnex
761
863
 
762
864
  def build_summary_actual
763
865
  counters = @event_counters.snapshot
764
- counters[:disconnections] = [counters[:disconnections], 1].max if @exit_reason == "disconnected"
866
+ if %w[disconnected boot_failure].include?(@exit_reason)
867
+ counters[:disconnections] = [counters[:disconnections], 1].max
868
+ end
765
869
 
766
- {
870
+ actual = {
767
871
  model: meta_hash["model"],
768
872
  effort: meta_hash["effort"],
769
873
  duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
@@ -785,6 +889,8 @@ module Harnex
785
889
  tests_passed: nil,
786
890
  tests_failed: nil
787
891
  }
892
+ actual[:last_error] = @last_error if @exit_reason == "boot_failure" && @last_error
893
+ actual
788
894
  end
789
895
 
790
896
  def summary_predicted_payload
@@ -813,6 +919,33 @@ module Harnex
813
919
  USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
814
920
  end
815
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
+
816
949
  def transcript_tail
817
950
  return "" unless File.file?(output_log_path)
818
951
 
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.6.3"
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.3
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.