harnex 0.5.0 → 0.6.2
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 +154 -0
- data/GUIDE.md +11 -11
- data/README.md +22 -16
- data/TECHNICAL.md +44 -60
- data/guides/01_dispatch.md +139 -0
- data/guides/02_chain.md +113 -0
- data/guides/03_buddy.md +94 -0
- data/guides/04_monitoring.md +130 -0
- data/guides/05_naming.md +106 -0
- data/lib/harnex/adapters/base.rb +10 -0
- data/lib/harnex/adapters/codex_appserver.rb +411 -0
- data/lib/harnex/adapters.rb +16 -3
- data/lib/harnex/cli.rb +16 -6
- data/lib/harnex/commands/agents_guide.rb +109 -0
- data/lib/harnex/commands/doctor.rb +83 -0
- data/lib/harnex/commands/events.rb +10 -0
- data/lib/harnex/commands/guide.rb +9 -0
- data/lib/harnex/commands/logs.rb +10 -0
- data/lib/harnex/commands/pane.rb +10 -0
- data/lib/harnex/commands/recipes.rb +9 -0
- data/lib/harnex/commands/run.rb +22 -4
- data/lib/harnex/commands/send.rb +13 -1
- data/lib/harnex/commands/status.rb +10 -0
- data/lib/harnex/commands/stop.rb +10 -0
- data/lib/harnex/commands/wait.rb +119 -3
- data/lib/harnex/core.rb +2 -2
- data/lib/harnex/runtime/session.rb +227 -1
- data/lib/harnex/runtime/session_state.rb +7 -0
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -1
- metadata +11 -9
- data/lib/harnex/commands/skills.rb +0 -226
- data/skills/close/SKILL.md +0 -47
- data/skills/harnex/SKILL.md +0 -20
- data/skills/harnex-buddy/SKILL.md +0 -104
- data/skills/harnex-chain/SKILL.md +0 -132
- data/skills/harnex-dispatch/SKILL.md +0 -294
- data/skills/open/SKILL.md +0 -32
|
@@ -7,6 +7,15 @@ module Harnex
|
|
|
7
7
|
Usage: harnex guide
|
|
8
8
|
|
|
9
9
|
Print the getting started guide.
|
|
10
|
+
|
|
11
|
+
Common patterns:
|
|
12
|
+
harnex guide
|
|
13
|
+
harnex agents-guide
|
|
14
|
+
harnex recipes
|
|
15
|
+
|
|
16
|
+
Gotchas:
|
|
17
|
+
guide is short human onboarding.
|
|
18
|
+
agents-guide is the deeper operational reference for dispatching agents.
|
|
10
19
|
TEXT
|
|
11
20
|
end
|
|
12
21
|
|
data/lib/harnex/commands/logs.rb
CHANGED
|
@@ -17,6 +17,16 @@ module Harnex
|
|
|
17
17
|
--follow Keep streaming appended output until session exit
|
|
18
18
|
--lines N Print the last N lines before following (default: #{DEFAULT_LINES})
|
|
19
19
|
-h, --help Show this help
|
|
20
|
+
|
|
21
|
+
Common patterns:
|
|
22
|
+
#{program_name} --id cx-i-42 --lines 80
|
|
23
|
+
#{program_name} --id cx-i-42 --follow
|
|
24
|
+
#{program_name} --id cx-i-42 --repo /path/to/repo --lines 200
|
|
25
|
+
|
|
26
|
+
Gotchas:
|
|
27
|
+
logs reads the persisted transcript, not the live tmux screen.
|
|
28
|
+
Use pane when you need the current TUI view or prompt text.
|
|
29
|
+
--follow streams until the live session exits.
|
|
20
30
|
TEXT
|
|
21
31
|
end
|
|
22
32
|
|
data/lib/harnex/commands/pane.rb
CHANGED
|
@@ -20,6 +20,16 @@ module Harnex
|
|
|
20
20
|
--interval N Refresh interval in seconds for --follow (default: #{FOLLOW_INTERVAL.to_i})
|
|
21
21
|
--json Output JSON with capture metadata
|
|
22
22
|
-h, --help Show this help
|
|
23
|
+
|
|
24
|
+
Common patterns:
|
|
25
|
+
#{program_name} --id cx-i-42 --lines 40
|
|
26
|
+
#{program_name} --id cx-i-42 --lines 40 --json
|
|
27
|
+
#{program_name} --id cx-i-42 --follow --interval 2
|
|
28
|
+
|
|
29
|
+
Gotchas:
|
|
30
|
+
pane requires a tmux-backed session.
|
|
31
|
+
Use --repo when the same ID exists in multiple repos or worktrees.
|
|
32
|
+
Do not use pane state alone as completion proof; verify artifacts/tests.
|
|
23
33
|
TEXT
|
|
24
34
|
end
|
|
25
35
|
|
|
@@ -17,6 +17,15 @@ module Harnex
|
|
|
17
17
|
harnex recipes list
|
|
18
18
|
harnex recipes show 01
|
|
19
19
|
harnex recipes show fire_and_watch
|
|
20
|
+
|
|
21
|
+
Common patterns:
|
|
22
|
+
harnex recipes show 01 # Fire and Watch
|
|
23
|
+
harnex recipes show 02 # Chain Implement
|
|
24
|
+
harnex recipes show 03 # Buddy
|
|
25
|
+
|
|
26
|
+
Gotchas:
|
|
27
|
+
Recipes are compact command walkthroughs.
|
|
28
|
+
Use `harnex agents-guide` for the deeper agent-facing guide.
|
|
20
29
|
TEXT
|
|
21
30
|
end
|
|
22
31
|
|
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 --help
|
|
11
|
+
--timeout --inbox-ttl --legacy-pty --help
|
|
12
12
|
].freeze
|
|
13
13
|
VALUE_FLAGS = %w[
|
|
14
14
|
--id --description --host --port --watch --watch-file --stall-after
|
|
@@ -36,6 +36,9 @@ module Harnex
|
|
|
36
36
|
--summary-out PATH Append dispatch telemetry summary JSONL to PATH
|
|
37
37
|
--timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
|
|
38
38
|
--inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
|
|
39
|
+
--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.
|
|
39
42
|
-h, --help Show this help
|
|
40
43
|
|
|
41
44
|
Notes:
|
|
@@ -45,6 +48,17 @@ module Harnex
|
|
|
45
48
|
CLIs with smart prompt detection: #{Adapters.known.join(', ')}
|
|
46
49
|
Any other CLI name is launched with generic wrapping.
|
|
47
50
|
Wrapper options may appear before or after <cli>.
|
|
51
|
+
|
|
52
|
+
Common patterns:
|
|
53
|
+
#{program_name} codex --id cx-i-42 --tmux cx-i-42 --context "Read /tmp/task-impl-42.md"
|
|
54
|
+
#{program_name} codex --id cx-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
|
|
55
|
+
#{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
|
|
56
|
+
|
|
57
|
+
Gotchas:
|
|
58
|
+
Always pair --id and --tmux with the same value for delegated work.
|
|
59
|
+
Passing --tmux without --id creates a random harnex session ID.
|
|
60
|
+
--watch is foreground-only; do not combine it with --tmux or --detach.
|
|
61
|
+
Use -- before child CLI flags when a flag could be parsed by harnex.
|
|
48
62
|
TEXT
|
|
49
63
|
end
|
|
50
64
|
|
|
@@ -70,6 +84,7 @@ module Harnex
|
|
|
70
84
|
tmux_name: nil,
|
|
71
85
|
timeout: DEFAULT_TIMEOUT,
|
|
72
86
|
inbox_ttl: default_inbox_ttl,
|
|
87
|
+
legacy_pty: false,
|
|
73
88
|
help: false
|
|
74
89
|
}
|
|
75
90
|
end
|
|
@@ -88,7 +103,7 @@ module Harnex
|
|
|
88
103
|
@options[:id] ||= Harnex.generate_id(repo_root)
|
|
89
104
|
validate_unique_id!(repo_root)
|
|
90
105
|
effective_child_args = apply_context(child_args)
|
|
91
|
-
adapter = Harnex.build_adapter(cli_name, effective_child_args)
|
|
106
|
+
adapter = Harnex.build_adapter(cli_name, effective_child_args, legacy_pty: @options[:legacy_pty])
|
|
92
107
|
@options[:detach] = true if @options[:tmux]
|
|
93
108
|
validate_watch_mode!
|
|
94
109
|
resolve_watch_preset!
|
|
@@ -146,6 +161,7 @@ module Harnex
|
|
|
146
161
|
tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
|
|
147
162
|
tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
|
|
148
163
|
tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
|
|
164
|
+
tmux_cmd += ["--legacy-pty"] if @options[:legacy_pty]
|
|
149
165
|
tmux_cmd += ["--"] + child_args unless child_args.empty?
|
|
150
166
|
|
|
151
167
|
window_name = @options[:tmux_name] || @options[:id]
|
|
@@ -254,7 +270,7 @@ module Harnex
|
|
|
254
270
|
end
|
|
255
271
|
|
|
256
272
|
def adapter_repo_path(cli_name, child_args)
|
|
257
|
-
Harnex.build_adapter(cli_name, child_args).infer_repo_path(child_args)
|
|
273
|
+
Harnex.build_adapter(cli_name, child_args, legacy_pty: @options[:legacy_pty]).infer_repo_path(child_args)
|
|
258
274
|
end
|
|
259
275
|
|
|
260
276
|
def apply_context(child_args)
|
|
@@ -415,6 +431,8 @@ module Harnex
|
|
|
415
431
|
@options[:inbox_ttl] = Float(required_option_value(arg, argv[index]))
|
|
416
432
|
when /\A--inbox-ttl=(.+)\z/
|
|
417
433
|
@options[:inbox_ttl] = Float(required_option_value("--inbox-ttl", Regexp.last_match(1)))
|
|
434
|
+
when "--legacy-pty"
|
|
435
|
+
@options[:legacy_pty] = true
|
|
418
436
|
else
|
|
419
437
|
if cli_name.nil?
|
|
420
438
|
cli_name = arg
|
|
@@ -454,7 +472,7 @@ module Harnex
|
|
|
454
472
|
case arg
|
|
455
473
|
when "--"
|
|
456
474
|
return false
|
|
457
|
-
when "-h", "--help", "--detach", "--tmux"
|
|
475
|
+
when "-h", "--help", "--detach", "--tmux", "--legacy-pty"
|
|
458
476
|
nil
|
|
459
477
|
when /\A--tmux=/
|
|
460
478
|
nil
|
data/lib/harnex/commands/send.rb
CHANGED
|
@@ -38,7 +38,19 @@ module Harnex
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def self.usage(program_name = "harnex send")
|
|
41
|
-
|
|
41
|
+
<<~TEXT
|
|
42
|
+
#{build_parser({}, program_name)}
|
|
43
|
+
Common patterns:
|
|
44
|
+
#{program_name} --id cx-i-42 --message "Read /tmp/task-impl-42.md" --wait-for-idle --timeout 900
|
|
45
|
+
#{program_name} --id cx-i-42 --message "Continue with the current task." --force
|
|
46
|
+
#{program_name} --id "$HARNEX_ID" --message "Worker finished; tests passed."
|
|
47
|
+
|
|
48
|
+
Gotchas:
|
|
49
|
+
--wait-for-idle is a turn fence, not proof that the whole task is done.
|
|
50
|
+
Use --no-wait for fire-and-forget delivery only when another monitor owns completion.
|
|
51
|
+
Long prompts are more reliable when written to a file and referenced by path.
|
|
52
|
+
Messages between harnex sessions get relay headers unless --no-relay is used.
|
|
53
|
+
TEXT
|
|
42
54
|
end
|
|
43
55
|
|
|
44
56
|
def initialize(argv)
|
|
@@ -19,6 +19,16 @@ module Harnex
|
|
|
19
19
|
--all List sessions across all repos
|
|
20
20
|
--json Output JSON instead of a table
|
|
21
21
|
-h, --help Show this help
|
|
22
|
+
|
|
23
|
+
Common patterns:
|
|
24
|
+
#{program_name}
|
|
25
|
+
#{program_name} --all
|
|
26
|
+
#{program_name} --id cx-i-42 --json
|
|
27
|
+
|
|
28
|
+
Gotchas:
|
|
29
|
+
By default, status filters to the current repo root.
|
|
30
|
+
Use --all when supervising workers launched from sibling worktrees.
|
|
31
|
+
A prompt-like state is not a completion signal by itself.
|
|
22
32
|
TEXT
|
|
23
33
|
end
|
|
24
34
|
|
data/lib/harnex/commands/stop.rb
CHANGED
|
@@ -23,6 +23,16 @@ module Harnex
|
|
|
23
23
|
|
|
24
24
|
Sends the adapter stop sequence to the session.
|
|
25
25
|
Use `harnex wait --id ID` afterward to block until the session finishes.
|
|
26
|
+
|
|
27
|
+
Common patterns:
|
|
28
|
+
#{program_name} --id cx-i-42
|
|
29
|
+
#{program_name} --id cx-i-42 --repo /path/to/repo
|
|
30
|
+
#{program_name} --id cx-i-42 --timeout 15
|
|
31
|
+
|
|
32
|
+
Gotchas:
|
|
33
|
+
Stop only after verifying the worker's result landed.
|
|
34
|
+
For tmux sessions, stop targets the harnex session ID, not the tmux name.
|
|
35
|
+
If a session is in another repo/worktree, pass --repo or run status --all.
|
|
26
36
|
TEXT
|
|
27
37
|
end
|
|
28
38
|
|
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -7,17 +7,33 @@ module Harnex
|
|
|
7
7
|
class Waiter
|
|
8
8
|
POLL_INTERVAL = 0.5
|
|
9
9
|
|
|
10
|
+
EVENT_PREDICATES = %w[task_complete].freeze
|
|
11
|
+
|
|
10
12
|
def self.usage(program_name = "harnex wait")
|
|
11
13
|
<<~TEXT
|
|
12
14
|
Usage: #{program_name} [options]
|
|
13
15
|
|
|
14
16
|
Options:
|
|
15
17
|
--id ID Session ID to wait for (required)
|
|
16
|
-
--until STATE Wait until session reaches STATE
|
|
17
|
-
|
|
18
|
+
--until STATE Wait until session reaches STATE. Supported:
|
|
19
|
+
task_complete (events JSONL — fires on
|
|
20
|
+
turn/completed; adapter-agnostic)
|
|
21
|
+
<other> (agent_state HTTP poll, e.g.
|
|
22
|
+
"prompt", "busy")
|
|
23
|
+
Without --until, waits for session exit (default).
|
|
18
24
|
--repo PATH Resolve session using PATH's repo root (default: current repo)
|
|
19
25
|
--timeout SECS Maximum time to wait in seconds (default: unlimited)
|
|
20
26
|
-h, --help Show this help
|
|
27
|
+
|
|
28
|
+
Common patterns:
|
|
29
|
+
#{program_name} --id cx-i-42 --until task_complete --timeout 900
|
|
30
|
+
#{program_name} --id cx-i-42 --until prompt --timeout 120
|
|
31
|
+
#{program_name} --id cx-i-42
|
|
32
|
+
|
|
33
|
+
Gotchas:
|
|
34
|
+
task_complete is an event predicate; prompt/busy are live state polls.
|
|
35
|
+
Prompt state alone does not prove work acceptance. Verify artifacts/tests.
|
|
36
|
+
Without --timeout, wait can block indefinitely.
|
|
21
37
|
TEXT
|
|
22
38
|
end
|
|
23
39
|
|
|
@@ -42,7 +58,11 @@ module Harnex
|
|
|
42
58
|
raise "--id is required for harnex wait" unless @options[:id]
|
|
43
59
|
|
|
44
60
|
if @options[:until_state]
|
|
45
|
-
|
|
61
|
+
if EVENT_PREDICATES.include?(@options[:until_state])
|
|
62
|
+
wait_until_event(@options[:until_state])
|
|
63
|
+
else
|
|
64
|
+
wait_until_state
|
|
65
|
+
end
|
|
46
66
|
else
|
|
47
67
|
wait_until_exit
|
|
48
68
|
end
|
|
@@ -50,6 +70,102 @@ module Harnex
|
|
|
50
70
|
|
|
51
71
|
private
|
|
52
72
|
|
|
73
|
+
def wait_until_event(predicate)
|
|
74
|
+
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
75
|
+
events_path = Harnex.events_log_path(repo_root, @options[:id])
|
|
76
|
+
registry = Harnex.read_registry(repo_root, @options[:id])
|
|
77
|
+
start_time = Time.now
|
|
78
|
+
deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
|
|
79
|
+
|
|
80
|
+
unless registry || File.exist?(events_path)
|
|
81
|
+
warn("harnex wait: no session found with id #{@options[:id].inspect}")
|
|
82
|
+
return 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
offset = 0
|
|
86
|
+
task_complete_seen = false
|
|
87
|
+
|
|
88
|
+
# Replay existing events first — we may already be past the predicate.
|
|
89
|
+
if File.exist?(events_path)
|
|
90
|
+
File.open(events_path, "r") do |f|
|
|
91
|
+
f.each_line do |line|
|
|
92
|
+
offset = f.pos
|
|
93
|
+
event = parse_event(line)
|
|
94
|
+
next unless event
|
|
95
|
+
task_complete_seen = true if event["type"] == "task_complete"
|
|
96
|
+
if matches?(event, predicate, task_complete_seen)
|
|
97
|
+
return emit_event_match(event, start_time)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
target_pid = registry && registry["pid"]
|
|
104
|
+
|
|
105
|
+
loop do
|
|
106
|
+
if target_pid && !Harnex.alive_pid?(target_pid)
|
|
107
|
+
waited = (Time.now - start_time).round(1)
|
|
108
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "exited", waited_seconds: waited)
|
|
109
|
+
return 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if deadline && Time.now >= deadline
|
|
113
|
+
waited = (Time.now - start_time).round(1)
|
|
114
|
+
puts JSON.generate(ok: false, id: @options[:id], status: "timeout", waited_seconds: waited)
|
|
115
|
+
return 124
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if File.exist?(events_path) && File.size(events_path) > offset
|
|
119
|
+
File.open(events_path, "r") do |f|
|
|
120
|
+
f.seek(offset)
|
|
121
|
+
f.each_line do |line|
|
|
122
|
+
event = parse_event(line)
|
|
123
|
+
next unless event
|
|
124
|
+
task_complete_seen = true if event["type"] == "task_complete"
|
|
125
|
+
if matches?(event, predicate, task_complete_seen)
|
|
126
|
+
offset = f.pos
|
|
127
|
+
return emit_event_match(event, start_time)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
offset = f.pos
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
sleep 0.1
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_event(line)
|
|
139
|
+
JSON.parse(line)
|
|
140
|
+
rescue JSON::ParserError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def matches?(event, predicate, task_complete_seen)
|
|
145
|
+
type = event["type"]
|
|
146
|
+
case predicate
|
|
147
|
+
when "task_complete"
|
|
148
|
+
type == "task_complete"
|
|
149
|
+
when "prompt"
|
|
150
|
+
type == "task_complete" ||
|
|
151
|
+
(task_complete_seen && type == "agent_state" && event["state"] == "prompt")
|
|
152
|
+
else
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def emit_event_match(event, start_time)
|
|
158
|
+
waited = (Time.now - start_time).round(1)
|
|
159
|
+
puts JSON.generate(
|
|
160
|
+
ok: true,
|
|
161
|
+
id: @options[:id],
|
|
162
|
+
event: event["type"],
|
|
163
|
+
seq: event["seq"],
|
|
164
|
+
waited_seconds: waited
|
|
165
|
+
)
|
|
166
|
+
0
|
|
167
|
+
end
|
|
168
|
+
|
|
53
169
|
def wait_until_state
|
|
54
170
|
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
55
171
|
target_state = @options[:until_state]
|
data/lib/harnex/core.rb
CHANGED
|
@@ -349,10 +349,10 @@ module Harnex
|
|
|
349
349
|
false
|
|
350
350
|
end
|
|
351
351
|
|
|
352
|
-
def build_adapter(cli, argv)
|
|
352
|
+
def build_adapter(cli, argv, legacy_pty: false)
|
|
353
353
|
raise ArgumentError, "cli is required" if cli.to_s.strip.empty?
|
|
354
354
|
|
|
355
|
-
Adapters.build(cli, argv)
|
|
355
|
+
Adapters.build(cli, argv, legacy_pty: legacy_pty)
|
|
356
356
|
end
|
|
357
357
|
|
|
358
358
|
def session_cli(session)
|
|
@@ -25,7 +25,7 @@ module Harnex
|
|
|
25
25
|
@counts[:stalls] += 1
|
|
26
26
|
when "resume"
|
|
27
27
|
@counts[:force_resumes] += 1
|
|
28
|
-
when "disconnect", "disconnection"
|
|
28
|
+
when "disconnect", "disconnection", "disconnected"
|
|
29
29
|
@counts[:disconnections] += 1
|
|
30
30
|
when "compaction"
|
|
31
31
|
@counts[:compactions] += 1
|
|
@@ -74,6 +74,7 @@ module Harnex
|
|
|
74
74
|
@usage_summary = {}
|
|
75
75
|
@ended_at = nil
|
|
76
76
|
@exit_reason = nil
|
|
77
|
+
@last_completed_at = nil
|
|
77
78
|
@writer = nil
|
|
78
79
|
@pid = nil
|
|
79
80
|
@term_signal = nil
|
|
@@ -105,6 +106,13 @@ module Harnex
|
|
|
105
106
|
validate_binary! if validate_binary
|
|
106
107
|
prepare_output_log
|
|
107
108
|
prepare_events_log
|
|
109
|
+
|
|
110
|
+
return run_jsonrpc if adapter.transport == :stdio_jsonrpc
|
|
111
|
+
|
|
112
|
+
run_pty
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def run_pty
|
|
108
116
|
@reader, @writer, @pid = PTY.spawn(child_env, *command)
|
|
109
117
|
@writer.sync = true
|
|
110
118
|
emit_started_event
|
|
@@ -179,6 +187,10 @@ module Harnex
|
|
|
179
187
|
payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
|
|
180
188
|
payload[:agent_state] = @state_machine.to_s
|
|
181
189
|
payload[:inbox] = @inbox.stats
|
|
190
|
+
payload[:last_completed_at] = @last_completed_at&.iso8601
|
|
191
|
+
payload[:model] = meta_hash["model"]
|
|
192
|
+
payload[:effort] = meta_hash["effort"]
|
|
193
|
+
payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
|
|
182
194
|
payload
|
|
183
195
|
end
|
|
184
196
|
|
|
@@ -193,6 +205,18 @@ module Harnex
|
|
|
193
205
|
end
|
|
194
206
|
|
|
195
207
|
def inject_stop
|
|
208
|
+
if adapter.transport == :stdio_jsonrpc
|
|
209
|
+
@inject_mutex.synchronize do
|
|
210
|
+
begin
|
|
211
|
+
adapter.interrupt
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
@state_machine.force_busy!
|
|
216
|
+
end
|
|
217
|
+
return { ok: true, signal: "interrupt_sent" }
|
|
218
|
+
end
|
|
219
|
+
|
|
196
220
|
raise "session is not running" unless pid && Harnex.alive_pid?(pid)
|
|
197
221
|
|
|
198
222
|
@inject_mutex.synchronize do
|
|
@@ -204,6 +228,10 @@ module Harnex
|
|
|
204
228
|
end
|
|
205
229
|
|
|
206
230
|
def inject_via_adapter(text:, submit:, enter_only:, force: false)
|
|
231
|
+
if adapter.transport == :stdio_jsonrpc
|
|
232
|
+
return inject_via_jsonrpc(text: text, submit: submit, enter_only: enter_only, force: force)
|
|
233
|
+
end
|
|
234
|
+
|
|
207
235
|
snapshot = adapter.wait_for_sendable(method(:screen_snapshot), submit: submit, enter_only: enter_only, force: force)
|
|
208
236
|
payload = adapter.build_send_payload(
|
|
209
237
|
text: text,
|
|
@@ -228,8 +256,43 @@ module Harnex
|
|
|
228
256
|
.tap { emit_send_event(text, force: payload[:force]) }
|
|
229
257
|
end
|
|
230
258
|
|
|
259
|
+
def inject_via_jsonrpc(text:, submit:, enter_only:, force: false)
|
|
260
|
+
payload = adapter.build_send_payload(
|
|
261
|
+
text: text,
|
|
262
|
+
submit: submit,
|
|
263
|
+
enter_only: enter_only,
|
|
264
|
+
screen_text: nil,
|
|
265
|
+
force: force
|
|
266
|
+
)
|
|
267
|
+
dispatch = payload.fetch(:dispatch).dup
|
|
268
|
+
dispatch[:model] = meta_hash["model"] if meta_hash["model"] && !dispatch.key?(:model)
|
|
269
|
+
dispatch[:effort] = meta_hash["effort"] if meta_hash["effort"] && !dispatch.key?(:effort)
|
|
270
|
+
|
|
271
|
+
turn_id = nil
|
|
272
|
+
@inject_mutex.synchronize do
|
|
273
|
+
turn_id = adapter.dispatch(**dispatch)
|
|
274
|
+
@state_machine.force_busy!
|
|
275
|
+
@injected_count += 1
|
|
276
|
+
@last_injected_at = Time.now
|
|
277
|
+
persist_registry
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
emit_send_event(dispatch.fetch(:prompt, text), force: payload[:force])
|
|
281
|
+
{
|
|
282
|
+
ok: true,
|
|
283
|
+
cli: adapter.key,
|
|
284
|
+
bytes_written: dispatch.fetch(:prompt, text).to_s.bytesize,
|
|
285
|
+
injected_count: @injected_count,
|
|
286
|
+
newline: false,
|
|
287
|
+
input_state: payload[:input_state],
|
|
288
|
+
force: payload[:force],
|
|
289
|
+
turn_id: turn_id
|
|
290
|
+
}
|
|
291
|
+
end
|
|
292
|
+
|
|
231
293
|
def sync_window_size
|
|
232
294
|
return unless STDIN.tty?
|
|
295
|
+
return unless @writer
|
|
233
296
|
|
|
234
297
|
@writer.winsize = STDIN.winsize
|
|
235
298
|
rescue StandardError
|
|
@@ -242,6 +305,169 @@ module Harnex
|
|
|
242
305
|
|
|
243
306
|
private
|
|
244
307
|
|
|
308
|
+
def run_jsonrpc
|
|
309
|
+
adapter.on_notification { |msg| handle_rpc_notification(msg) }
|
|
310
|
+
adapter.on_disconnect { |err| handle_rpc_disconnect(err) }
|
|
311
|
+
|
|
312
|
+
adapter.start_rpc(env: child_env, cwd: repo_root)
|
|
313
|
+
@pid = adapter.pid
|
|
314
|
+
@state_machine.force_prompt!
|
|
315
|
+
emit_started_event
|
|
316
|
+
emit_git_start_event
|
|
317
|
+
|
|
318
|
+
install_signal_handlers
|
|
319
|
+
@server = ApiServer.new(self)
|
|
320
|
+
@server.start
|
|
321
|
+
persist_registry
|
|
322
|
+
|
|
323
|
+
watch_thread = start_watch_thread
|
|
324
|
+
@inbox.start
|
|
325
|
+
dispatch_initial_prompt
|
|
326
|
+
|
|
327
|
+
if @pid
|
|
328
|
+
begin
|
|
329
|
+
_, status = Process.wait2(@pid)
|
|
330
|
+
@term_signal = status.signaled? ? status.termsig : nil
|
|
331
|
+
@exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
|
|
332
|
+
rescue Errno::ECHILD
|
|
333
|
+
@exit_code = 0
|
|
334
|
+
end
|
|
335
|
+
else
|
|
336
|
+
@rpc_done_lock = Mutex.new
|
|
337
|
+
@rpc_done_cond = ConditionVariable.new
|
|
338
|
+
@rpc_done_lock.synchronize { @rpc_done_cond.wait(@rpc_done_lock) until @rpc_done }
|
|
339
|
+
@exit_code = 0
|
|
340
|
+
end
|
|
341
|
+
@ended_at = Time.now
|
|
342
|
+
|
|
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
|
|
349
|
+
watch_thread&.kill
|
|
350
|
+
@exit_code
|
|
351
|
+
ensure
|
|
352
|
+
@inbox.stop
|
|
353
|
+
@server&.stop
|
|
354
|
+
begin
|
|
355
|
+
adapter.close
|
|
356
|
+
rescue StandardError
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
|
+
persist_exit_status
|
|
360
|
+
cleanup_registry
|
|
361
|
+
@output_log&.close unless @output_log&.closed?
|
|
362
|
+
@events_log&.close unless @events_log&.closed?
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def signal_rpc_done!
|
|
366
|
+
@rpc_done = true
|
|
367
|
+
if defined?(@rpc_done_lock) && @rpc_done_lock
|
|
368
|
+
@rpc_done_lock.synchronize { @rpc_done_cond&.signal }
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def handle_rpc_notification(message)
|
|
373
|
+
method = message["method"]
|
|
374
|
+
params = message["params"] || {}
|
|
375
|
+
|
|
376
|
+
case method
|
|
377
|
+
when "thread/started"
|
|
378
|
+
@rpc_thread_id = params["threadId"] || params["thread_id"]
|
|
379
|
+
when "turn/started"
|
|
380
|
+
@state_machine.force_busy!
|
|
381
|
+
emit_event("turn_started", turnId: params["turnId"] || params["turn_id"])
|
|
382
|
+
when "turn/completed"
|
|
383
|
+
@last_completed_at = Time.now
|
|
384
|
+
@state_machine.force_prompt!
|
|
385
|
+
payload = { turnId: params["turnId"] || params["turn_id"] }
|
|
386
|
+
payload[:status] = params["status"] if params["status"]
|
|
387
|
+
payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"]
|
|
388
|
+
emit_event("task_complete", **payload)
|
|
389
|
+
when "item/completed"
|
|
390
|
+
emit_event("item_completed", item: params["item"])
|
|
391
|
+
text = render_item_text(params["item"])
|
|
392
|
+
record_synthesized(text) if text
|
|
393
|
+
when "thread/compacted"
|
|
394
|
+
emit_event("compaction", **params)
|
|
395
|
+
when "thread/tokenUsage/updated"
|
|
396
|
+
# Surfaced via status fields in Phase 4; no event spam.
|
|
397
|
+
@token_usage = params["usage"] || params
|
|
398
|
+
when "thread/status/changed"
|
|
399
|
+
# State machine reflects RPC state; no event needed.
|
|
400
|
+
nil
|
|
401
|
+
when "account/rateLimits/updated"
|
|
402
|
+
@rate_limits = params
|
|
403
|
+
when "error"
|
|
404
|
+
@state_machine.force_busy!
|
|
405
|
+
emit_event("disconnected", source: "error_notification", message: params["message"])
|
|
406
|
+
signal_rpc_done!
|
|
407
|
+
end
|
|
408
|
+
rescue StandardError => e
|
|
409
|
+
warn("harnex: rpc notification handler error: #{e.message}")
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def handle_rpc_disconnect(error)
|
|
413
|
+
msg = error.is_a?(Hash) ? error["message"] : error&.message
|
|
414
|
+
@state_machine.force_busy!
|
|
415
|
+
emit_event("disconnected", source: "transport", message: msg) rescue nil
|
|
416
|
+
signal_rpc_done!
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def dispatch_initial_prompt
|
|
420
|
+
return unless adapter.respond_to?(:initial_prompt)
|
|
421
|
+
|
|
422
|
+
prompt = adapter.initial_prompt
|
|
423
|
+
return if prompt.to_s.empty?
|
|
424
|
+
|
|
425
|
+
inject_via_jsonrpc(text: prompt, submit: true, enter_only: false, force: false)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def render_item_text(item)
|
|
429
|
+
return nil unless item.is_a?(Hash)
|
|
430
|
+
|
|
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)}" : ""}"
|
|
439
|
+
else
|
|
440
|
+
item["text"]
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def summarize(value)
|
|
445
|
+
str = value.is_a?(String) ? value : JSON.generate(value)
|
|
446
|
+
str.length > 120 ? "#{str[0, 117]}..." : str
|
|
447
|
+
rescue StandardError
|
|
448
|
+
""
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def record_synthesized(text)
|
|
452
|
+
return if text.nil? || text.to_s.empty?
|
|
453
|
+
|
|
454
|
+
payload = text.to_s.dup
|
|
455
|
+
payload << "\n" unless payload.end_with?("\n")
|
|
456
|
+
bytes = payload.b
|
|
457
|
+
@mutex.synchronize do
|
|
458
|
+
append_output_log(bytes)
|
|
459
|
+
@output_buffer << bytes
|
|
460
|
+
overflow = @output_buffer.bytesize - OUTPUT_BUFFER_LIMIT
|
|
461
|
+
@output_buffer = @output_buffer.byteslice(overflow, OUTPUT_BUFFER_LIMIT) if overflow.positive?
|
|
462
|
+
end
|
|
463
|
+
begin
|
|
464
|
+
STDOUT.write(payload)
|
|
465
|
+
STDOUT.flush
|
|
466
|
+
rescue StandardError
|
|
467
|
+
nil
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
245
471
|
def child_env
|
|
246
472
|
env = {
|
|
247
473
|
"HARNEX_SESSION_ID" => session_id,
|
|
@@ -36,6 +36,13 @@ module Harnex
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def force_prompt!
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@state = :prompt
|
|
42
|
+
@condvar.broadcast
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
39
46
|
def wait_for_prompt(timeout)
|
|
40
47
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
41
48
|
@mutex.synchronize do
|
data/lib/harnex/version.rb
CHANGED
data/lib/harnex.rb
CHANGED
|
@@ -24,5 +24,6 @@ require_relative "harnex/commands/events"
|
|
|
24
24
|
require_relative "harnex/commands/pane"
|
|
25
25
|
require_relative "harnex/commands/recipes"
|
|
26
26
|
require_relative "harnex/commands/guide"
|
|
27
|
-
require_relative "harnex/commands/
|
|
27
|
+
require_relative "harnex/commands/agents_guide"
|
|
28
|
+
require_relative "harnex/commands/doctor"
|
|
28
29
|
require_relative "harnex/cli"
|