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
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
module Harnex
|
|
5
|
+
module Adapters
|
|
6
|
+
# Codex `app-server` adapter — JSON-RPC over stdio.
|
|
7
|
+
#
|
|
8
|
+
# Talks to a spawned `codex app-server` subprocess by writing
|
|
9
|
+
# newline-delimited JSON-RPC messages on stdin and reading
|
|
10
|
+
# responses + notifications from stdout. Replaces the pane-scraping
|
|
11
|
+
# heuristics in `Adapters::Codex` (legacy, kept behind --legacy-pty).
|
|
12
|
+
class CodexAppServer < Base
|
|
13
|
+
CLIENT_TITLE = "harnex"
|
|
14
|
+
CLIENT_NAME = "harnex"
|
|
15
|
+
|
|
16
|
+
OPT_OUT_NOTIFICATIONS = %w[
|
|
17
|
+
item/agentMessage/delta
|
|
18
|
+
item/reasoning/summaryTextDelta
|
|
19
|
+
item/reasoning/summaryPartAdded
|
|
20
|
+
item/reasoning/textDelta
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
REQUEST_METHODS = %w[
|
|
24
|
+
initialize thread/start turn/start turn/interrupt thread/resume
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
NOTIFICATION_METHODS = %w[
|
|
28
|
+
thread/started turn/started turn/completed
|
|
29
|
+
item/started item/completed
|
|
30
|
+
thread/status/changed thread/tokenUsage/updated
|
|
31
|
+
thread/compacted account/rateLimits/updated
|
|
32
|
+
error
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
EVENTS = %w[task_complete turn_started item_completed disconnected].freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :thread_id, :current_turn_id, :last_completed_at, :initial_prompt
|
|
38
|
+
|
|
39
|
+
def initialize(extra_args = [])
|
|
40
|
+
super("codex", extra_args)
|
|
41
|
+
@initial_prompt = extra_args.join(" ").strip
|
|
42
|
+
@initial_prompt = nil if @initial_prompt.empty?
|
|
43
|
+
@client = nil
|
|
44
|
+
@thread_id = nil
|
|
45
|
+
@current_turn_id = nil
|
|
46
|
+
@state = :disconnected
|
|
47
|
+
@last_completed_at = nil
|
|
48
|
+
@notification_handler = nil
|
|
49
|
+
@disconnect_handler = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def transport
|
|
53
|
+
:stdio_jsonrpc
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def base_command
|
|
57
|
+
["codex", "app-server"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_command
|
|
61
|
+
base_command
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def describe
|
|
65
|
+
{
|
|
66
|
+
transport: transport,
|
|
67
|
+
request_methods: REQUEST_METHODS,
|
|
68
|
+
notification_methods: NOTIFICATION_METHODS,
|
|
69
|
+
events: EVENTS
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def state
|
|
74
|
+
@state
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Override: state is RPC-driven, screen text is ignored.
|
|
78
|
+
def input_state(_screen_text = nil)
|
|
79
|
+
{
|
|
80
|
+
state: @state.to_s,
|
|
81
|
+
input_ready: @state == :prompt
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
|
|
86
|
+
state = input_state(nil)
|
|
87
|
+
if !force && submit && !enter_only && state[:input_ready] != true
|
|
88
|
+
raise ArgumentError, blocked_message(state, enter_only: enter_only)
|
|
89
|
+
end
|
|
90
|
+
raise ArgumentError, "Codex app-server cannot stage input without submitting it" unless submit || enter_only
|
|
91
|
+
raise ArgumentError, "Codex app-server does not support submit-only input" if enter_only
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
dispatch: { prompt: text.to_s },
|
|
95
|
+
input_state: state,
|
|
96
|
+
force: force
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# No-op: closing the subprocess is handled via #close.
|
|
101
|
+
def inject_exit(_writer, **_kwargs)
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def on_notification(&block)
|
|
106
|
+
@notification_handler = block
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def on_disconnect(&block)
|
|
110
|
+
@disconnect_handler = block
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Start the JSON-RPC client. In production, spawns the codex
|
|
114
|
+
# subprocess. In tests, callers may pass pre-built IO objects.
|
|
115
|
+
def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
|
|
116
|
+
if read_io && write_io
|
|
117
|
+
@client = JsonRpcClient.new(read_io: read_io, write_io: write_io, pid: pid)
|
|
118
|
+
else
|
|
119
|
+
spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
|
|
120
|
+
@client = JsonRpcClient.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@client.on_notification { |msg| handle_notification(msg) }
|
|
124
|
+
@client.on_disconnect { |err| handle_disconnect(err) }
|
|
125
|
+
@client.start
|
|
126
|
+
perform_handshake
|
|
127
|
+
@state = :prompt
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def dispatch(prompt:, model: nil, effort: nil)
|
|
132
|
+
ensure_open!
|
|
133
|
+
ensure_thread!
|
|
134
|
+
params = {
|
|
135
|
+
threadId: @thread_id,
|
|
136
|
+
input: { content: [{ type: "text", text: prompt.to_s }] }
|
|
137
|
+
}
|
|
138
|
+
params[:model] = model if model
|
|
139
|
+
params[:effort] = effort if effort
|
|
140
|
+
|
|
141
|
+
result = @client.request("turn/start", params)
|
|
142
|
+
@current_turn_id = result["turnId"] || result["turn_id"] || result["id"]
|
|
143
|
+
@state = :busy
|
|
144
|
+
@current_turn_id
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def interrupt(turn_id: nil)
|
|
148
|
+
ensure_open!
|
|
149
|
+
target = turn_id || @current_turn_id
|
|
150
|
+
return nil if target.nil?
|
|
151
|
+
|
|
152
|
+
@client.request("turn/interrupt", { threadId: @thread_id, turnId: target })
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def resume(thread_id:)
|
|
156
|
+
ensure_open!
|
|
157
|
+
result = @client.request("thread/resume", { threadId: thread_id })
|
|
158
|
+
@thread_id = thread_id
|
|
159
|
+
@state = :prompt
|
|
160
|
+
result
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def close
|
|
164
|
+
return unless @client
|
|
165
|
+
|
|
166
|
+
@client.close
|
|
167
|
+
@client = nil
|
|
168
|
+
@state = :disconnected
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def pid
|
|
172
|
+
@client&.pid
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def ensure_open!
|
|
178
|
+
raise "codex_appserver: client not started" unless @client
|
|
179
|
+
raise "codex_appserver: disconnected" if @state == :disconnected
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def ensure_thread!
|
|
183
|
+
return if @thread_id
|
|
184
|
+
|
|
185
|
+
result = @client.request("thread/start", {})
|
|
186
|
+
@thread_id = result["threadId"] || result["thread_id"]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def perform_handshake
|
|
190
|
+
@client.request("initialize", {
|
|
191
|
+
clientInfo: {
|
|
192
|
+
title: CLIENT_TITLE,
|
|
193
|
+
name: CLIENT_NAME,
|
|
194
|
+
version: Harnex::VERSION
|
|
195
|
+
},
|
|
196
|
+
capabilities: {
|
|
197
|
+
experimentalApi: false,
|
|
198
|
+
optOutNotificationMethods: OPT_OUT_NOTIFICATIONS
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
@client.notify("initialized", {})
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def handle_notification(message)
|
|
205
|
+
method = message["method"]
|
|
206
|
+
params = message["params"] || {}
|
|
207
|
+
|
|
208
|
+
case method
|
|
209
|
+
when "thread/started"
|
|
210
|
+
@thread_id ||= params["threadId"] || params["thread_id"]
|
|
211
|
+
when "turn/started"
|
|
212
|
+
@current_turn_id = params["turnId"] || params["turn_id"]
|
|
213
|
+
@state = :busy
|
|
214
|
+
when "turn/completed"
|
|
215
|
+
@last_completed_at = Time.now
|
|
216
|
+
@current_turn_id = nil
|
|
217
|
+
@state = :prompt
|
|
218
|
+
when "error"
|
|
219
|
+
@state = :disconnected
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
@notification_handler&.call(message)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def handle_disconnect(error)
|
|
226
|
+
@state = :disconnected
|
|
227
|
+
@disconnect_handler&.call(error)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def spawn_subprocess(env, cwd)
|
|
231
|
+
spawn_env = env || {}
|
|
232
|
+
opts = {}
|
|
233
|
+
opts[:chdir] = cwd if cwd
|
|
234
|
+
stdin_io, stdout_io, _stderr_io, wait_thr =
|
|
235
|
+
Open3.popen3(spawn_env, *build_command, **opts)
|
|
236
|
+
[wait_thr.pid, stdin_io, stdout_io]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def blocked_message(state, enter_only:)
|
|
240
|
+
return super if enter_only
|
|
241
|
+
|
|
242
|
+
"Codex app-server is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Minimal JSON-RPC 2.0 client. One JSON object per line.
|
|
246
|
+
# Responses keyed by id; everything else is a notification.
|
|
247
|
+
class JsonRpcClient
|
|
248
|
+
attr_reader :pid
|
|
249
|
+
|
|
250
|
+
def initialize(read_io:, write_io:, pid: nil)
|
|
251
|
+
@read_io = read_io
|
|
252
|
+
@write_io = write_io
|
|
253
|
+
@pid = pid
|
|
254
|
+
@next_id = 1
|
|
255
|
+
@pending = {}
|
|
256
|
+
@id_mutex = Mutex.new
|
|
257
|
+
@write_mutex = Mutex.new
|
|
258
|
+
@notification_handler = nil
|
|
259
|
+
@disconnect_handler = nil
|
|
260
|
+
@disconnect_signaled = false
|
|
261
|
+
@closed = false
|
|
262
|
+
@reader_thread = nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def on_notification(&block)
|
|
266
|
+
@notification_handler = block
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def on_disconnect(&block)
|
|
270
|
+
@disconnect_handler = block
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def start
|
|
274
|
+
@reader_thread = Thread.new { read_loop }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def request(method, params = {})
|
|
278
|
+
raise "codex_appserver client is closed" if @closed
|
|
279
|
+
|
|
280
|
+
queue = Queue.new
|
|
281
|
+
id = @id_mutex.synchronize do
|
|
282
|
+
assigned = @next_id
|
|
283
|
+
@next_id += 1
|
|
284
|
+
@pending[assigned] = queue
|
|
285
|
+
assigned
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
|
|
289
|
+
result = queue.pop
|
|
290
|
+
raise result if result.is_a?(Exception)
|
|
291
|
+
|
|
292
|
+
result
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def notify(method, params = {})
|
|
296
|
+
return if @closed
|
|
297
|
+
|
|
298
|
+
write_line({ jsonrpc: "2.0", method: method, params: params })
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def close
|
|
302
|
+
return if @closed
|
|
303
|
+
|
|
304
|
+
@closed = true
|
|
305
|
+
|
|
306
|
+
@id_mutex.synchronize do
|
|
307
|
+
@pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
|
|
308
|
+
@pending.clear
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
begin
|
|
312
|
+
@write_io.close unless @write_io.closed?
|
|
313
|
+
rescue IOError
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
if @pid && process_alive?(@pid)
|
|
318
|
+
sleep 0.05
|
|
319
|
+
begin
|
|
320
|
+
Process.kill("TERM", @pid)
|
|
321
|
+
rescue Errno::ESRCH
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
@reader_thread&.join(2)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
def write_line(message)
|
|
332
|
+
@write_mutex.synchronize do
|
|
333
|
+
@write_io.write(JSON.generate(message))
|
|
334
|
+
@write_io.write("\n")
|
|
335
|
+
@write_io.flush
|
|
336
|
+
end
|
|
337
|
+
rescue Errno::EPIPE, IOError
|
|
338
|
+
signal_disconnect(nil)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def read_loop
|
|
342
|
+
buffer = +""
|
|
343
|
+
loop do
|
|
344
|
+
chunk = @read_io.readpartial(4096)
|
|
345
|
+
buffer << chunk
|
|
346
|
+
while (idx = buffer.index("\n"))
|
|
347
|
+
line = buffer.slice!(0, idx + 1).chomp
|
|
348
|
+
next if line.strip.empty?
|
|
349
|
+
|
|
350
|
+
handle_line(line)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
rescue EOFError, IOError, Errno::EIO
|
|
354
|
+
nil
|
|
355
|
+
ensure
|
|
356
|
+
signal_disconnect(nil)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def handle_line(line)
|
|
360
|
+
message = JSON.parse(line)
|
|
361
|
+
rescue JSON::ParserError => e
|
|
362
|
+
signal_disconnect(e)
|
|
363
|
+
return
|
|
364
|
+
else
|
|
365
|
+
dispatch_message(message)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def dispatch_message(message)
|
|
369
|
+
if message["id"] && message["method"]
|
|
370
|
+
write_line({
|
|
371
|
+
jsonrpc: "2.0",
|
|
372
|
+
id: message["id"],
|
|
373
|
+
error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
|
|
374
|
+
})
|
|
375
|
+
return
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
if message.key?("id")
|
|
379
|
+
pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
|
|
380
|
+
return unless pending
|
|
381
|
+
|
|
382
|
+
if message["error"]
|
|
383
|
+
err_msg = message.dig("error", "message") || "RPC error"
|
|
384
|
+
pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
|
|
385
|
+
signal_disconnect(message["error"])
|
|
386
|
+
else
|
|
387
|
+
pending.push(message["result"] || {})
|
|
388
|
+
end
|
|
389
|
+
return
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
@notification_handler&.call(message) if message["method"]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def signal_disconnect(error)
|
|
396
|
+
return if @disconnect_signaled
|
|
397
|
+
|
|
398
|
+
@disconnect_signaled = true
|
|
399
|
+
@disconnect_handler&.call(error)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def process_alive?(pid)
|
|
403
|
+
Process.kill(0, pid)
|
|
404
|
+
true
|
|
405
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
406
|
+
false
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
data/lib/harnex/adapters.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require_relative "adapters/base"
|
|
2
2
|
require_relative "adapters/generic"
|
|
3
3
|
require_relative "adapters/codex"
|
|
4
|
+
require_relative "adapters/codex_appserver"
|
|
4
5
|
require_relative "adapters/claude"
|
|
5
6
|
|
|
6
7
|
module Harnex
|
|
@@ -15,11 +16,23 @@ module Harnex
|
|
|
15
16
|
!key.to_s.strip.empty?
|
|
16
17
|
end
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
# Phase 3 flipped the default — `codex` resolves to CodexAppServer.
|
|
20
|
+
# Legacy PTY adapter is reachable via `legacy_pty: true` (driven by
|
|
21
|
+
# `harnex run codex --legacy-pty`). Will be removed in 0.7.0.
|
|
22
|
+
def codex_appserver_enabled?
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build(key, extra_args = [], legacy_pty: false)
|
|
27
|
+
key_str = key.to_s
|
|
28
|
+
if key_str == "codex"
|
|
29
|
+
return legacy_pty ? Codex.new(extra_args) : CodexAppServer.new(extra_args)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
adapter_class = registry[key_str]
|
|
20
33
|
return adapter_class.new(extra_args) if adapter_class
|
|
21
34
|
|
|
22
|
-
Generic.new(
|
|
35
|
+
Generic.new(key_str, extra_args)
|
|
23
36
|
end
|
|
24
37
|
|
|
25
38
|
def registry
|
data/lib/harnex/cli.rb
CHANGED
|
@@ -29,8 +29,10 @@ module Harnex
|
|
|
29
29
|
Recipes.new(@argv.drop(1)).run
|
|
30
30
|
when "guide"
|
|
31
31
|
Guide.new.run
|
|
32
|
-
when "
|
|
33
|
-
|
|
32
|
+
when "agents-guide"
|
|
33
|
+
AgentsGuide.new(@argv.drop(1)).run
|
|
34
|
+
when "doctor"
|
|
35
|
+
Doctor.new(@argv.drop(1)).run
|
|
34
36
|
when "help"
|
|
35
37
|
puts help(@argv[1])
|
|
36
38
|
0
|
|
@@ -69,8 +71,10 @@ module Harnex
|
|
|
69
71
|
Recipes.usage
|
|
70
72
|
when "guide"
|
|
71
73
|
Guide.usage
|
|
72
|
-
when "
|
|
73
|
-
|
|
74
|
+
when "agents-guide"
|
|
75
|
+
AgentsGuide.usage
|
|
76
|
+
when "doctor"
|
|
77
|
+
Doctor.usage
|
|
74
78
|
else
|
|
75
79
|
usage
|
|
76
80
|
end
|
|
@@ -87,6 +91,8 @@ module Harnex
|
|
|
87
91
|
harnex logs --id ID [options]
|
|
88
92
|
harnex events --id ID [options]
|
|
89
93
|
harnex pane --id ID [options]
|
|
94
|
+
harnex agents-guide [topic]
|
|
95
|
+
harnex doctor
|
|
90
96
|
harnex help [command]
|
|
91
97
|
|
|
92
98
|
Commands:
|
|
@@ -100,10 +106,13 @@ module Harnex
|
|
|
100
106
|
pane Capture the current tmux pane for a live session
|
|
101
107
|
recipes List and read workflow recipes
|
|
102
108
|
guide Show the getting started guide
|
|
103
|
-
|
|
109
|
+
agents-guide
|
|
110
|
+
Show agent dispatch, chain, buddy, monitoring, and naming guides
|
|
111
|
+
doctor Run preflight checks for adapter dependencies
|
|
104
112
|
help Show command help
|
|
105
113
|
|
|
106
114
|
New to harnex? Start with: harnex guide
|
|
115
|
+
Working with agents (dispatching tasks)? Read: harnex agents-guide
|
|
107
116
|
|
|
108
117
|
Notes:
|
|
109
118
|
CLIs with smart prompt detection: #{Adapters.known.join(', ')}
|
|
@@ -117,8 +126,9 @@ module Harnex
|
|
|
117
126
|
harnex logs --id main --follow
|
|
118
127
|
harnex events --id main --snapshot
|
|
119
128
|
harnex pane --id main --lines 40
|
|
129
|
+
harnex agents-guide dispatch
|
|
130
|
+
harnex doctor
|
|
120
131
|
harnex send --id main --message "Summarize current progress."
|
|
121
|
-
harnex skills install
|
|
122
132
|
TEXT
|
|
123
133
|
end
|
|
124
134
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
class AgentsGuide
|
|
3
|
+
GUIDES_DIR = File.expand_path("../../../../guides", __FILE__)
|
|
4
|
+
|
|
5
|
+
def self.usage
|
|
6
|
+
<<~TEXT
|
|
7
|
+
Usage: harnex agents-guide [list|show <topic>|<topic>]
|
|
8
|
+
|
|
9
|
+
Subcommands:
|
|
10
|
+
list List available agent guide topics (default)
|
|
11
|
+
show <topic> Print a guide by name or number
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
harnex agents-guide
|
|
15
|
+
harnex agents-guide list
|
|
16
|
+
harnex agents-guide show 01
|
|
17
|
+
harnex agents-guide show dispatch
|
|
18
|
+
harnex agents-guide monitoring
|
|
19
|
+
|
|
20
|
+
Common patterns:
|
|
21
|
+
harnex agents-guide dispatch
|
|
22
|
+
harnex agents-guide chain
|
|
23
|
+
harnex agents-guide naming
|
|
24
|
+
|
|
25
|
+
Gotchas:
|
|
26
|
+
Agent guides replace the old harnex skills install flow.
|
|
27
|
+
They are packaged with the gem and require no external project docs.
|
|
28
|
+
TEXT
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(argv)
|
|
32
|
+
@argv = argv.dup
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
subcommand = @argv.shift
|
|
37
|
+
case subcommand
|
|
38
|
+
when nil, "list"
|
|
39
|
+
list_guides
|
|
40
|
+
when "show"
|
|
41
|
+
show_guide(@argv.first)
|
|
42
|
+
when "-h", "--help"
|
|
43
|
+
puts self.class.usage
|
|
44
|
+
0
|
|
45
|
+
else
|
|
46
|
+
show_guide(subcommand)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def list_guides
|
|
53
|
+
files = guide_files
|
|
54
|
+
if files.empty?
|
|
55
|
+
puts "No agent guides found."
|
|
56
|
+
return 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
puts "Agent guides:\n\n"
|
|
60
|
+
files.each do |file|
|
|
61
|
+
name = File.basename(file, ".md")
|
|
62
|
+
title = extract_title(file)
|
|
63
|
+
puts " #{name} #{title}"
|
|
64
|
+
end
|
|
65
|
+
puts "\nRun `harnex agents-guide show <topic>` to read one."
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def show_guide(query)
|
|
70
|
+
unless query
|
|
71
|
+
warn("harnex agents-guide show: topic required")
|
|
72
|
+
return 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
file = find_guide(query)
|
|
76
|
+
unless file
|
|
77
|
+
warn("harnex agents-guide: no topic matching #{query.inspect}")
|
|
78
|
+
warn("Run `harnex agents-guide list` to see available topics.")
|
|
79
|
+
return 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
puts File.read(file)
|
|
83
|
+
0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_guide(query)
|
|
87
|
+
files = guide_files
|
|
88
|
+
|
|
89
|
+
exact = files.find { |file| File.basename(file, ".md") == query }
|
|
90
|
+
return exact if exact
|
|
91
|
+
|
|
92
|
+
prefix = files.find { |file| File.basename(file, ".md").start_with?(query) }
|
|
93
|
+
return prefix if prefix
|
|
94
|
+
|
|
95
|
+
files.find { |file| File.basename(file, ".md").include?(query) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def guide_files
|
|
99
|
+
return [] unless Dir.exist?(GUIDES_DIR)
|
|
100
|
+
|
|
101
|
+
Dir.glob(File.join(GUIDES_DIR, "*.md")).sort
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_title(file)
|
|
105
|
+
first_line = File.foreach(file).first.to_s.strip
|
|
106
|
+
first_line.start_with?("#") ? first_line.sub(/^#+\s*/, "") : ""
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Harnex
|
|
4
|
+
class Doctor
|
|
5
|
+
MIN_CODEX_VERSION = Gem::Version.new("0.128.0")
|
|
6
|
+
|
|
7
|
+
def self.usage
|
|
8
|
+
<<~TEXT
|
|
9
|
+
Usage: harnex doctor
|
|
10
|
+
|
|
11
|
+
Runs preflight checks for harnex's adapter dependencies.
|
|
12
|
+
Currently verifies that Codex CLI is installed and at version
|
|
13
|
+
>= #{MIN_CODEX_VERSION} (required for the JSON-RPC `app-server`
|
|
14
|
+
adapter).
|
|
15
|
+
|
|
16
|
+
Common patterns:
|
|
17
|
+
harnex doctor
|
|
18
|
+
harnex doctor --help
|
|
19
|
+
|
|
20
|
+
Gotchas:
|
|
21
|
+
doctor validates local adapter prerequisites; it does not start sessions.
|
|
22
|
+
Run it after installing or upgrading Codex CLI.
|
|
23
|
+
TEXT
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(argv = [])
|
|
27
|
+
@argv = argv.dup
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
if @argv.include?("-h") || @argv.include?("--help")
|
|
32
|
+
puts self.class.usage
|
|
33
|
+
return 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
checks = [check_codex]
|
|
37
|
+
summary = {
|
|
38
|
+
ok: checks.all? { |c| c[:ok] },
|
|
39
|
+
checks: checks
|
|
40
|
+
}
|
|
41
|
+
puts JSON.generate(summary)
|
|
42
|
+
summary[:ok] ? 0 : 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def check_codex
|
|
48
|
+
result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
|
|
49
|
+
|
|
50
|
+
version_output, status = capture("codex --version")
|
|
51
|
+
if status.nil?
|
|
52
|
+
return result.merge(ok: false, error: "codex CLI not found on PATH")
|
|
53
|
+
end
|
|
54
|
+
unless status.success?
|
|
55
|
+
return result.merge(ok: false, error: "codex --version failed: #{version_output.strip}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
version = parse_version(version_output)
|
|
59
|
+
if version.nil?
|
|
60
|
+
return result.merge(ok: false, found: version_output.strip, error: "could not parse codex version output")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if version < MIN_CODEX_VERSION
|
|
64
|
+
return result.merge(ok: false, found: version.to_s,
|
|
65
|
+
error: "codex #{version} < required #{MIN_CODEX_VERSION}; upgrade with `npm i -g @openai/codex` or your platform package manager")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
result.merge(ok: true, found: version.to_s)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def capture(command)
|
|
72
|
+
output = `#{command} 2>&1`
|
|
73
|
+
[output, $?]
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
[e.message, nil]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_version(text)
|
|
79
|
+
match = text.match(/(\d+\.\d+\.\d+)/)
|
|
80
|
+
match ? Gem::Version.new(match[1]) : nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -18,6 +18,16 @@ module Harnex
|
|
|
18
18
|
--snapshot Print current events and exit (alias for --no-follow)
|
|
19
19
|
--from TS Replay floor (ISO-8601, inclusive)
|
|
20
20
|
-h, --help Show this help
|
|
21
|
+
|
|
22
|
+
Common patterns:
|
|
23
|
+
#{program_name} --id cx-i-42 --snapshot
|
|
24
|
+
#{program_name} --id cx-i-42
|
|
25
|
+
#{program_name} --id cx-i-42 --from 2026-05-06T10:00:00Z --snapshot
|
|
26
|
+
|
|
27
|
+
Gotchas:
|
|
28
|
+
events is structured JSONL; logs is human transcript text.
|
|
29
|
+
Default mode follows live events. Use --snapshot to print and exit.
|
|
30
|
+
Use wait --until task_complete when you only need a completion fence.
|
|
21
31
|
TEXT
|
|
22
32
|
end
|
|
23
33
|
|