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.
@@ -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
 
@@ -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
 
@@ -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
 
@@ -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
@@ -38,7 +38,19 @@ module Harnex
38
38
  end
39
39
 
40
40
  def self.usage(program_name = "harnex send")
41
- build_parser({}, program_name).to_s
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
 
@@ -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
 
@@ -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 (e.g. "prompt")
17
- Without --until, waits for session exit (default)
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
- wait_until_state
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
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.5.0"
3
- RELEASE_DATE = "2026-05-01"
2
+ VERSION = "0.6.2"
3
+ RELEASE_DATE = "2026-05-06"
4
4
  end
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/skills"
27
+ require_relative "harnex/commands/agents_guide"
28
+ require_relative "harnex/commands/doctor"
28
29
  require_relative "harnex/cli"