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 +4 -4
- data/CHANGELOG.md +32 -0
- data/TECHNICAL.md +1 -0
- data/guides/01_dispatch.md +20 -0
- data/lib/harnex/adapters/codex_appserver.rb +63 -4
- data/lib/harnex/adapters/generic.rb +11 -0
- data/lib/harnex/commands/run.rb +21 -3
- data/lib/harnex/runtime/session.rb +154 -34
- data/lib/harnex/version.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 297484ce749d268b7582afd226d617c79fed80a12928ced5daa8df2c3bd4f763
|
|
4
|
+
data.tar.gz: 554528411e6bca5c6037ca579067f37b4b58626b88ed5c263fe95debe4517f70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/guides/01_dispatch.md
CHANGED
|
@@ -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: "
|
|
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
|
|
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")
|
|
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
|
|
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
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
388
|
-
payload
|
|
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
|
-
#
|
|
399
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
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(
|
|
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
|
|
data/lib/harnex/version.rb
CHANGED
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
|
+
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-
|
|
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.
|