harnex 0.2.0

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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/GUIDE.md +242 -0
  3. data/LICENSE +21 -0
  4. data/README.md +119 -0
  5. data/TECHNICAL.md +595 -0
  6. data/bin/harnex +18 -0
  7. data/lib/harnex/adapters/base.rb +134 -0
  8. data/lib/harnex/adapters/claude.rb +105 -0
  9. data/lib/harnex/adapters/codex.rb +112 -0
  10. data/lib/harnex/adapters/generic.rb +14 -0
  11. data/lib/harnex/adapters.rb +32 -0
  12. data/lib/harnex/cli.rb +115 -0
  13. data/lib/harnex/commands/guide.rb +23 -0
  14. data/lib/harnex/commands/logs.rb +184 -0
  15. data/lib/harnex/commands/pane.rb +251 -0
  16. data/lib/harnex/commands/recipes.rb +104 -0
  17. data/lib/harnex/commands/run.rb +384 -0
  18. data/lib/harnex/commands/send.rb +415 -0
  19. data/lib/harnex/commands/skills.rb +163 -0
  20. data/lib/harnex/commands/status.rb +171 -0
  21. data/lib/harnex/commands/stop.rb +127 -0
  22. data/lib/harnex/commands/wait.rb +165 -0
  23. data/lib/harnex/core.rb +286 -0
  24. data/lib/harnex/runtime/api_server.rb +187 -0
  25. data/lib/harnex/runtime/file_change_hook.rb +111 -0
  26. data/lib/harnex/runtime/inbox.rb +207 -0
  27. data/lib/harnex/runtime/message.rb +23 -0
  28. data/lib/harnex/runtime/session.rb +380 -0
  29. data/lib/harnex/runtime/session_state.rb +55 -0
  30. data/lib/harnex/version.rb +3 -0
  31. data/lib/harnex/watcher/inotify.rb +43 -0
  32. data/lib/harnex/watcher/polling.rb +92 -0
  33. data/lib/harnex/watcher.rb +24 -0
  34. data/lib/harnex.rb +25 -0
  35. data/recipes/01_fire_and_watch.md +82 -0
  36. data/recipes/02_chain_implement.md +115 -0
  37. data/skills/chain-implement/SKILL.md +234 -0
  38. data/skills/close/SKILL.md +47 -0
  39. data/skills/dispatch/SKILL.md +171 -0
  40. data/skills/harnex/SKILL.md +304 -0
  41. data/skills/open/SKILL.md +32 -0
  42. metadata +88 -0
@@ -0,0 +1,415 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "optparse"
4
+ require "uri"
5
+
6
+ module Harnex
7
+ class Sender
8
+ DEFAULT_TIMEOUT = 120.0
9
+ MIN_HTTP_TIMEOUT = 0.1
10
+ POLL_INTERVAL = 0.5
11
+ IDLE_FENCE_TIMEOUT = 30.0
12
+ class TimeoutError < RuntimeError; end
13
+
14
+ def self.build_parser(options, program_name = "harnex send")
15
+ OptionParser.new do |opts|
16
+ opts.banner = "Usage: #{program_name} --id ID [options] [text...]"
17
+ opts.on("--id ID", "Target session ID") { |value| options[:id] = Harnex.normalize_id(value) }
18
+ opts.on("--repo PATH", "Resolve the target using PATH's repo root") { |value| options[:repo_path] = value }
19
+ opts.on("--cli CLI", "Filter by CLI type") { |value| options[:cli] = value }
20
+ opts.on("--message TEXT", "Message text to inject instead of positional args") { |value| options[:message] = value }
21
+ opts.on("--no-submit", "Inject text without pressing Enter") { options[:submit] = false }
22
+ opts.on("--submit-only", "Press Enter without injecting text") do
23
+ options[:submit_only] = true
24
+ options[:submit] = true
25
+ end
26
+ opts.on("--force", "Send even if the agent is not at a prompt") { options[:force] = true }
27
+ opts.on("--no-wait", "Return immediately after queueing (HTTP 202). Use for fire-and-forget or when polling delivery separately.") { options[:wait] = false }
28
+ opts.on("--wait-for-idle", "After a successful send, wait for the agent to return to prompt") { options[:wait_for_idle] = true }
29
+ opts.on("--relay", "Force relay header formatting") { options[:relay] = true }
30
+ opts.on("--no-relay", "Disable automatic relay headers") { options[:relay] = false }
31
+ opts.on("--port PORT", Integer, "Send directly to a specific port") { |value| options[:port] = value }
32
+ opts.on("--token TOKEN", "Auth token for --port mode") { |value| options[:token] = value }
33
+ opts.on("--host HOST", "Override the host when --port is used") { |value| options[:host] = value }
34
+ opts.on("--timeout SECS", Float, "How long to wait for lookup, delivery, or idle wait (default: #{DEFAULT_TIMEOUT.to_i})") { |value| options[:timeout] = value }
35
+ opts.on("--verbose", "Print lookup and delivery details to stderr") { options[:verbose] = true }
36
+ opts.on("-h", "--help", "Show help") { options[:help] = true }
37
+ end
38
+ end
39
+
40
+ def self.usage(program_name = "harnex send")
41
+ build_parser({}, program_name).to_s
42
+ end
43
+
44
+ def initialize(argv)
45
+ @options = {
46
+ repo_path: Dir.pwd,
47
+ id: nil,
48
+ cli: nil,
49
+ message: nil,
50
+ submit: true,
51
+ submit_only: false,
52
+ relay: nil,
53
+ force: false,
54
+ wait: true,
55
+ wait_for_idle: false,
56
+ port: nil,
57
+ token: nil,
58
+ host: DEFAULT_HOST,
59
+ timeout: DEFAULT_TIMEOUT,
60
+ verbose: false,
61
+ help: false
62
+ }
63
+ @argv = argv.dup
64
+ end
65
+
66
+ def run
67
+ parser.parse!(@argv)
68
+ if @options[:help]
69
+ puts parser
70
+ return 0
71
+ end
72
+
73
+ raise "--id is required for harnex send" unless @options[:id]
74
+ validate_modes!
75
+
76
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
77
+ @command_started_at = monotonic_now
78
+ deadline = @command_started_at + @options[:timeout]
79
+ verbose("repo_root=#{repo_root}")
80
+ verbose("id=#{@options[:id]}")
81
+ verbose("cli=#{@options[:cli] || '(any)'}")
82
+
83
+ registry = wait_for_registry(repo_root, deadline: deadline)
84
+ case registry
85
+ when :ambiguous
86
+ raise ambiguous_session_message(repo_root)
87
+ when :timeout
88
+ puts JSON.generate(ok: false, id: @options[:id], status: "timeout", error: "lookup timed out after #{@options[:timeout]}s")
89
+ return 124
90
+ end
91
+
92
+ registry = direct_registry.merge(registry || {})
93
+ verbose("target=http://#{registry.fetch('host')}:#{registry.fetch('port')}/send")
94
+
95
+ text = resolve_text
96
+ text = relay_text(text, registry)
97
+ raise "text is required" if text.to_s.empty? && !@options[:submit_only]
98
+
99
+ response = with_http_retry(deadline: deadline) do
100
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/send")
101
+ request = Net::HTTP::Post.new(uri)
102
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
103
+ request["Content-Type"] = "application/json"
104
+ request.body = JSON.generate(
105
+ text: text,
106
+ submit: @options[:submit],
107
+ enter_only: @options[:submit_only],
108
+ force: @options[:force]
109
+ )
110
+
111
+ Net::HTTP.start(
112
+ uri.host,
113
+ uri.port,
114
+ open_timeout: http_timeout(deadline),
115
+ read_timeout: http_timeout(deadline)
116
+ ) { |http| http.request(request) }
117
+ end
118
+
119
+ result = nil
120
+ exit_code = nil
121
+
122
+ if response.code == "202" && @options[:wait]
123
+ parsed = parse_json_body(response.body)
124
+ message_id = parsed["message_id"]
125
+ if message_id
126
+ verbose("queued message_id=#{message_id}")
127
+ result = poll_delivery(registry, message_id, deadline: deadline)
128
+ exit_code =
129
+ case result["status"]
130
+ when "timeout" then 124
131
+ when "delivered" then 0
132
+ else 1
133
+ end
134
+ end
135
+ end
136
+
137
+ unless result
138
+ result = parse_json_body(response.body)
139
+ exit_code = response.is_a?(Net::HTTPSuccess) ? 0 : 1
140
+ end
141
+
142
+ if exit_code == 0 && @options[:wait_for_idle]
143
+ result = wait_for_idle_state(registry, deadline)
144
+ exit_code = result["ok"] ? 0 : (result["state"] == "exited" ? 1 : 124)
145
+ end
146
+
147
+ puts JSON.generate(result)
148
+ exit_code
149
+ rescue TimeoutError => e
150
+ puts JSON.generate(ok: false, id: @options[:id], status: "timeout", error: e.message)
151
+ 124
152
+ end
153
+
154
+ private
155
+
156
+ def resolve_text
157
+ return "" if @options[:submit_only]
158
+ return @options[:message] if @options[:message]
159
+ return @argv.join(" ") unless @argv.empty?
160
+ return STDIN.read unless STDIN.tty?
161
+
162
+ raise "harnex send: no message provided (use --message, positional args, or pipe)"
163
+ end
164
+
165
+ def poll_delivery(registry, message_id, deadline:)
166
+ base = "http://#{registry.fetch('host')}:#{registry.fetch('port')}"
167
+ uri = URI("#{base}/messages/#{message_id}")
168
+
169
+ loop do
170
+ return({ "ok" => false, "status" => "timeout", "error" => timeout_message("delivery") }) if deadline_reached?(deadline)
171
+
172
+ request = Net::HTTP::Get.new(uri)
173
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
174
+ response = Net::HTTP.start(
175
+ uri.host,
176
+ uri.port,
177
+ open_timeout: http_timeout(deadline, cap: 1.0),
178
+ read_timeout: http_timeout(deadline, cap: 2.0)
179
+ ) { |http| http.request(request) }
180
+ parsed = parse_json_body(response.body)
181
+ status = parsed["status"]
182
+
183
+ return parsed if %w[delivered failed].include?(status)
184
+ return parsed.merge("status" => "timeout", "error" => timeout_message("delivery")) if deadline_reached?(deadline)
185
+
186
+ verbose("delivery status=#{status || 'unknown'}")
187
+ sleep 0.25
188
+ rescue StandardError => e
189
+ return({ "ok" => false, "status" => "timeout", "error" => timeout_message("delivery") }) if deadline_reached?(deadline)
190
+
191
+ sleep 0.25
192
+ end
193
+ end
194
+
195
+ def wait_for_idle_state(registry, deadline)
196
+ host = registry.fetch("host")
197
+ port = registry.fetch("port")
198
+ token = registry["token"]
199
+ last_state = "prompt"
200
+ fence_deadline = [monotonic_now + IDLE_FENCE_TIMEOUT, deadline].min
201
+
202
+ loop do
203
+ return wait_for_idle_timeout(last_state) if deadline_reached?(deadline)
204
+
205
+ state = fetch_agent_state(host, port, token)
206
+ return wait_for_idle_exited if state.nil?
207
+
208
+ last_state = state
209
+ break if state != "prompt"
210
+ return wait_for_idle_success("prompt") if monotonic_now >= fence_deadline
211
+
212
+ sleep POLL_INTERVAL
213
+ end
214
+
215
+ loop do
216
+ return wait_for_idle_timeout(last_state) if deadline_reached?(deadline)
217
+
218
+ state = fetch_agent_state(host, port, token)
219
+ return wait_for_idle_exited if state.nil?
220
+
221
+ last_state = state
222
+ return wait_for_idle_success(state) if state == "prompt"
223
+
224
+ sleep POLL_INTERVAL
225
+ end
226
+ end
227
+
228
+ def relay_text(text, registry)
229
+ return text if text.to_s.empty?
230
+ return text unless relay_enabled_for?(registry)
231
+ return text if text.lstrip.start_with?("[harnex relay ")
232
+
233
+ context = Harnex.current_session_context
234
+ Harnex.format_relay_message(
235
+ text,
236
+ from: context.fetch(:cli),
237
+ id: context.fetch(:id)
238
+ )
239
+ end
240
+
241
+ def relay_enabled_for?(registry)
242
+ return false if @options[:submit_only]
243
+ return false if @options[:relay] == false
244
+
245
+ context = Harnex.current_session_context
246
+ return false unless context
247
+ return true if @options[:relay] == true
248
+
249
+ target_session_id = registry["session_id"].to_s
250
+ return false if target_session_id.empty?
251
+
252
+ target_session_id != context.fetch(:session_id)
253
+ end
254
+
255
+ def verbose(message)
256
+ return unless @options[:verbose]
257
+
258
+ warn("[harnex send] #{message}")
259
+ end
260
+
261
+ def wait_for_registry(repo_root, deadline:)
262
+ return nil if @options[:port]
263
+
264
+ loop do
265
+ registry = resolve_registry(repo_root)
266
+ return registry unless registry == :retry
267
+ return :timeout if deadline_reached?(deadline)
268
+
269
+ sleep 0.1
270
+ end
271
+ end
272
+
273
+ def resolve_registry(repo_root)
274
+ sessions = Harnex.active_sessions(repo_root, id: @options[:id], cli: @options[:cli])
275
+ return :retry if sessions.empty?
276
+ return sessions.first if sessions.length == 1
277
+
278
+ :ambiguous
279
+ end
280
+
281
+ def direct_registry
282
+ return {} unless @options[:port]
283
+
284
+ registry = {
285
+ "host" => @options[:host],
286
+ "port" => @options[:port]
287
+ }
288
+ registry["token"] = @options[:token] if @options[:token]
289
+ registry
290
+ end
291
+
292
+ def missing_session_message(repo_root)
293
+ base = "no active harnex session found for #{repo_root}"
294
+ filters = []
295
+ filters << "id: #{@options[:id]}" if @options[:id]
296
+ filters << "cli: #{@options[:cli]}" if @options[:cli]
297
+ return "#{base} (#{filters.join(', ')})" unless filters.empty?
298
+
299
+ available = Harnex.active_sessions(repo_root).map { |session| "#{session['id']}(#{Harnex.session_cli(session)})" }.uniq.sort
300
+ suffix = available.empty? ? "" : " | active: #{available.join(', ')}"
301
+ "#{base}#{suffix}"
302
+ end
303
+
304
+ def ambiguous_session_message(repo_root)
305
+ available = Harnex.active_sessions(repo_root, id: @options[:id], cli: @options[:cli])
306
+ detail = available.map { |session| "#{session['id']}(#{Harnex.session_cli(session)})" }.join(", ")
307
+ filters = []
308
+ filters << "id #{@options[:id].inspect}" if @options[:id]
309
+ filters << "cli #{@options[:cli].inspect}" if @options[:cli]
310
+ scope = filters.empty? ? repo_root : "#{repo_root} with #{filters.join(' and ')}"
311
+ "multiple active harnex sessions found for #{scope}; use --id, --cli, or `harnex status` | active: #{detail}"
312
+ end
313
+
314
+ def with_http_retry(deadline:)
315
+ loop do
316
+ raise TimeoutError, timeout_message("request") if deadline_reached?(deadline)
317
+
318
+ return yield
319
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, Net::ReadTimeout, Net::OpenTimeout => e
320
+ raise TimeoutError, timeout_message("request") if deadline_reached?(deadline)
321
+
322
+ verbose("retrying after #{e.class}")
323
+ sleep 0.1
324
+ end
325
+ end
326
+
327
+ def fetch_agent_state(host, port, token)
328
+ uri = URI("http://#{host}:#{port}/status")
329
+ request = Net::HTTP::Get.new(uri)
330
+ request["Authorization"] = "Bearer #{token}" if token
331
+
332
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
333
+ http.request(request)
334
+ end
335
+
336
+ return nil unless response.is_a?(Net::HTTPSuccess)
337
+
338
+ data = JSON.parse(response.body)
339
+ data["agent_state"]
340
+ rescue StandardError
341
+ nil
342
+ end
343
+
344
+ def monotonic_now
345
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
346
+ end
347
+
348
+ def deadline_reached?(deadline)
349
+ monotonic_now >= deadline
350
+ end
351
+
352
+ def http_timeout(deadline, cap: nil)
353
+ remaining = deadline - monotonic_now
354
+ remaining = [remaining, MIN_HTTP_TIMEOUT].max
355
+ cap ? [remaining, cap].min : remaining
356
+ end
357
+
358
+ def timeout_message(phase)
359
+ "#{phase} timed out after #{@options[:timeout]}s"
360
+ end
361
+
362
+ def waited_seconds
363
+ return 0.0 unless @command_started_at
364
+
365
+ (monotonic_now - @command_started_at).round(1)
366
+ end
367
+
368
+ def wait_for_idle_success(state)
369
+ {
370
+ "ok" => true,
371
+ "id" => @options[:id],
372
+ "state" => state,
373
+ "waited_seconds" => waited_seconds
374
+ }
375
+ end
376
+
377
+ def wait_for_idle_timeout(state)
378
+ {
379
+ "ok" => false,
380
+ "id" => @options[:id],
381
+ "state" => state || "unknown",
382
+ "error" => "timeout",
383
+ "waited_seconds" => waited_seconds
384
+ }
385
+ end
386
+
387
+ def wait_for_idle_exited
388
+ {
389
+ "ok" => false,
390
+ "id" => @options[:id],
391
+ "state" => "exited",
392
+ "waited_seconds" => waited_seconds
393
+ }
394
+ end
395
+
396
+ def parse_json_body(body)
397
+ JSON.parse(body.to_s.empty? ? "{}" : body.to_s)
398
+ rescue JSON::ParserError
399
+ { "ok" => false, "error" => "invalid json response", "raw_body" => body.to_s }
400
+ end
401
+
402
+ def validate_modes!
403
+ raise "--submit-only cannot be combined with --no-submit" if @options[:submit_only] && !@options[:submit]
404
+
405
+ return unless @options[:submit_only]
406
+ return if @options[:message].nil? && @argv.empty?
407
+
408
+ raise "--submit-only does not accept message text"
409
+ end
410
+
411
+ def parser
412
+ @parser ||= self.class.build_parser(@options)
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,163 @@
1
+ require "fileutils"
2
+
3
+ module Harnex
4
+ class Skills
5
+ SKILLS_ROOT = File.expand_path("../../../../skills", __FILE__)
6
+ DEFAULT_SKILL = "harnex"
7
+
8
+ def self.usage
9
+ <<~TEXT
10
+ Usage: harnex skills install [SKILL...] [--global]
11
+
12
+ Subcommands:
13
+ install Install bundled skills into the current repo
14
+
15
+ Options:
16
+ --global Install to ~/.claude/skills and ~/.codex/skills
17
+ instead of the current repo
18
+
19
+ Available skills: #{bundled_skill_names.join(', ')}
20
+
21
+ With no SKILL argument, installs #{DEFAULT_SKILL.inspect}.
22
+ Pass one or more skill names to install specific skills.
23
+
24
+ Without --global, copies the skill to .claude/skills/<skill>/
25
+ in the current repo and symlinks .codex/skills/<skill> to it.
26
+
27
+ With --global, symlinks both ~/.claude/skills/<skill> and
28
+ ~/.codex/skills/<skill> to the bundled source.
29
+ TEXT
30
+ end
31
+
32
+ def self.bundled_skill_names
33
+ Dir.children(SKILLS_ROOT).select { |name| File.directory?(File.join(SKILLS_ROOT, name)) }.sort
34
+ end
35
+
36
+ def initialize(argv)
37
+ @argv = argv.dup
38
+ end
39
+
40
+ def run
41
+ subcommand = @argv.shift
42
+ case subcommand
43
+ when "install"
44
+ skill_names, global, help = parse_install_args(@argv)
45
+ if help
46
+ puts self.class.usage
47
+ return 0
48
+ end
49
+
50
+ skill_names.each do |skill_name|
51
+ skill_source = resolve_skill_source(skill_name)
52
+ unless skill_source
53
+ return missing_skill(skill_name)
54
+ end
55
+
56
+ result = global ? install_global(skill_name, skill_source) : install_local(skill_name, skill_source)
57
+ return result unless result == 0
58
+ end
59
+ 0
60
+ when "-h", "--help", nil
61
+ puts self.class.usage
62
+ 0
63
+ else
64
+ warn("harnex skills: unknown subcommand #{subcommand.inspect}")
65
+ puts self.class.usage
66
+ 1
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def parse_install_args(args)
73
+ skill_names = []
74
+ global = false
75
+ help = false
76
+
77
+ args.each do |arg|
78
+ case arg
79
+ when "--global"
80
+ global = true
81
+ when "-h", "--help"
82
+ help = true
83
+ when /\A-/
84
+ raise "harnex skills: unknown option #{arg.inspect}"
85
+ else
86
+ skill_names << arg
87
+ end
88
+ end
89
+
90
+ skill_names = [DEFAULT_SKILL] if skill_names.empty?
91
+
92
+ [skill_names, global, help]
93
+ end
94
+
95
+ def resolve_skill_source(skill_name)
96
+ return nil unless self.class.bundled_skill_names.include?(skill_name)
97
+
98
+ File.join(SKILLS_ROOT, skill_name)
99
+ end
100
+
101
+ def missing_skill(skill_name)
102
+ warn("harnex skills: unknown skill #{skill_name.inspect}")
103
+ available = self.class.bundled_skill_names
104
+ warn("available skills: #{available.join(', ')}") unless available.empty?
105
+ 1
106
+ end
107
+
108
+ def install_local(skill_name, skill_source)
109
+ repo_root = Harnex.resolve_repo_root(Dir.pwd)
110
+ claude_dir = File.join(repo_root, ".claude", "skills", skill_name)
111
+ codex_dir = File.join(repo_root, ".codex", "skills", skill_name)
112
+
113
+ # Copy skill to .claude/skills/<skill>/
114
+ if Dir.exist?(claude_dir)
115
+ warn("harnex skills: #{claude_dir} already exists, overwriting")
116
+ FileUtils.rm_rf(claude_dir)
117
+ end
118
+ FileUtils.mkdir_p(File.dirname(claude_dir))
119
+ FileUtils.cp_r(skill_source, claude_dir)
120
+ puts "installed #{claude_dir}"
121
+
122
+ # Symlink .codex/skills/<skill> -> .claude/skills/<skill>
123
+ codex_parent = File.dirname(codex_dir)
124
+ FileUtils.mkdir_p(codex_parent)
125
+ FileUtils.rm_rf(codex_dir) if File.exist?(codex_dir) || File.symlink?(codex_dir)
126
+
127
+ # Relative symlink so it works if the repo moves
128
+ relative = relative_path(from: codex_parent, to: claude_dir)
129
+ File.symlink(relative, codex_dir)
130
+ puts "symlinked #{codex_dir} -> #{relative}"
131
+
132
+ 0
133
+ end
134
+
135
+ def install_global(skill_name, skill_source)
136
+ claude_dir = File.expand_path("~/.claude/skills/#{skill_name}")
137
+ codex_dir = File.expand_path("~/.codex/skills/#{skill_name}")
138
+
139
+ [claude_dir, codex_dir].each do |dir|
140
+ parent = File.dirname(dir)
141
+ FileUtils.mkdir_p(parent)
142
+ FileUtils.rm_rf(dir) if File.exist?(dir) || File.symlink?(dir)
143
+ File.symlink(skill_source, dir)
144
+ puts "symlinked #{dir} -> #{skill_source}"
145
+ end
146
+
147
+ 0
148
+ end
149
+
150
+ def relative_path(from:, to:)
151
+ from_parts = File.expand_path(from).split("/")
152
+ to_parts = File.expand_path(to).split("/")
153
+
154
+ # Drop common prefix
155
+ while from_parts.first == to_parts.first && !from_parts.empty?
156
+ from_parts.shift
157
+ to_parts.shift
158
+ end
159
+
160
+ ([".."] * from_parts.length + to_parts).join("/")
161
+ end
162
+ end
163
+ end