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 +4 -4
- data/CHANGELOG.md +74 -0
- data/TECHNICAL.md +12 -1
- data/guides/01_dispatch.md +20 -0
- data/lib/harnex/adapters/codex_appserver.rb +128 -9
- data/lib/harnex/adapters/generic.rb +11 -0
- data/lib/harnex/adapters.rb +2 -1
- data/lib/harnex/commands/run.rb +24 -5
- data/lib/harnex/runtime/session.rb +169 -36
- 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
|
@@ -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`,
|
|
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
|
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,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
|
|
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")
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/harnex/adapters.rb
CHANGED
|
@@ -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`)
|
|
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
|
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,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.
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
386
|
-
payload
|
|
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
|
-
#
|
|
397
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|