harnex 0.29.0 → 0.30.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.
data/lib/harnex/lanes.rb CHANGED
@@ -19,6 +19,9 @@ module Harnex
19
19
  WORKTREES_DIR = File.join(".harnex", "worktrees").freeze
20
20
  CLOSED_STATES = %w[landed discarded].freeze
21
21
  DEFAULT_TIMEOUT = 540
22
+ # A lane's run is tagged meta.phase = its kind unless the caller names a
23
+ # phase; repo phase allowlists exempt these harnex-owned phases (#88).
24
+ PHASES = %w[delegate ask].freeze
22
25
 
23
26
  module_function
24
27
 
@@ -60,21 +63,56 @@ module Harnex
60
63
  lane && !CLOSED_STATES.include?(lane["state"])
61
64
  end
62
65
 
66
+ # Older harnex kept the record of a lane refused before it started (#88).
67
+ def never_started?(lane)
68
+ lane && lane["state"] == "failed" && lane["turns"].to_i.zero? && !live?(lane)
69
+ end
70
+
71
+ def forget(lane)
72
+ FileUtils.rm_f(path(lane["repo_root"], lane["name"]))
73
+ FileUtils.rm_f(brief_path(lane["repo_root"], lane["name"]))
74
+ end
75
+
63
76
  # A name resolves to this repo's lane first, then to the only lane
64
- # anywhere with that name.
77
+ # anywhere whose worker is running (those names are unique, #89), then
78
+ # in flight, open, or at all.
65
79
  def resolve(name, from: Dir.pwd)
66
80
  root = main_root(from)
67
81
  lane = root && load(root, name)
68
82
  return lane if lane
69
83
 
70
84
  matches = all.select { |candidate| candidate["name"] == name }
71
- return matches.first if matches.one?
72
85
  raise OptionParser::InvalidArgument, "no lane named #{name.inspect} (see `harnex lanes`)" if matches.empty?
73
86
 
87
+ [matches.select { |l| open?(l) && live?(l) }, matches.select { |l| in_flight?(l) },
88
+ matches.select { |l| open?(l) }, matches].each do |group|
89
+ return group.first if group.one?
90
+ end
74
91
  raise OptionParser::InvalidArgument,
75
92
  "several lanes are named #{name.inspect} (#{matches.map { |l| l['repo_root'] }.join(', ')}); run from the lane's repo"
76
93
  end
77
94
 
95
+ # Working (or finished but uncollected), or a worktree not yet landed,
96
+ # in a repo that still exists.
97
+ def in_flight?(lane)
98
+ open?(lane) && !never_started?(lane) && (lane["state"] == "running" || !lane["branch"].nil?) &&
99
+ File.directory?(lane["repo_root"].to_s)
100
+ end
101
+
102
+ # #89: the in-flight lane anywhere that holds `name`, if any. Generated
103
+ # names skip these, so `harnex result NAME` and `reply NAME` find their
104
+ # lane from anywhere.
105
+ def holder(name)
106
+ all.find { |lane| lane["name"] == name && in_flight?(lane) }
107
+ end
108
+
109
+ # The lane anywhere whose worker is running under `name`, if any. A name
110
+ # someone chooses is refused only while another repo's worker has it, so
111
+ # stale lanes elsewhere (an old eval, an abandoned clone) never block it.
112
+ def live_holder(name)
113
+ all.find { |lane| lane["name"] == name && open?(lane) && live?(lane) }
114
+ end
115
+
78
116
  # Lanes this caller started in this repo whose current turn nobody has
79
117
  # collected yet.
80
118
  def pending(from: Dir.pwd, owner: Harnex.ambient_owner_id)
@@ -85,20 +123,32 @@ module Harnex
85
123
 
86
124
  FILLER = %w[a an the this that these those to of in on at for from with and or but it its please
87
125
  run use add make have has can could would should will do does just then also your you
88
- our my me i we gpt worker workers task repo repository git command file files code].freeze
89
-
90
- def generate_name(root, task, prefix: "pi")
91
- words = task.to_s.downcase.scan(/[a-z0-9]+/).reject { |word| FILLER.include?(word) }.first(4)
92
- base = ([prefix] + words).join("-")[0, 40].sub(/-+\z/, "")
126
+ our my me i we gpt worker workers task repo repository git command file files code
127
+ are is be carry out brief lane].freeze
128
+
129
+ # A lane working in another worktree of the repo is named after that
130
+ # worktree (`pi-night-1024_s3`); otherwise after the task's first words.
131
+ # Either way no repo has the name in flight (#89).
132
+ def generate_name(root, task, cwd: nil, prefix: "pi")
133
+ base = ([prefix] + name_words(root, task, cwd)).join("-")[0, 40].sub(/[-_.]+\z/, "")
93
134
  name = base
94
135
  index = 2
95
- while open?(load(root, name)) || Harnex.read_registry(root, name)
136
+ while open?(load(root, name)) || holder(name) || Harnex.read_registry(root, name)
96
137
  name = "#{base}-#{index}"
97
138
  index += 1
98
139
  end
99
140
  name
100
141
  end
101
142
 
143
+ def name_words(root, task, cwd)
144
+ top = cwd && toplevel(cwd)
145
+ if top && root && !same_dir?(top, root)
146
+ slug = File.basename(top).downcase.gsub(/[^a-z0-9._-]+/, "-").sub(/\A[^a-z0-9]+/, "")
147
+ return [slug] unless slug.empty?
148
+ end
149
+ task.to_s.downcase.scan(/[a-z0-9]+/).reject { |word| FILLER.include?(word) || word.match?(/\A[a-z]\z/) }.first(4)
150
+ end
151
+
102
152
  def live?(lane)
103
153
  registry = Harnex.read_registry(lane["run_root"], lane["name"])
104
154
  !registry.nil? && Harnex.alive_pid?(registry["pid"])
@@ -142,17 +192,43 @@ module Harnex
142
192
  end
143
193
 
144
194
  # The main checkout of the repo that contains `path` (also from inside one
145
- # of its worktrees), or nil outside git.
195
+ # of its worktrees), or nil outside git. In a bare layout (`repo/.bare`
196
+ # plus sibling worktrees, #89) it is the worktree on the bare repo's HEAD
197
+ # branch, so every worktree of the repo shares one lane namespace.
146
198
  def main_root(path)
147
199
  common, _err, ok = git(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
148
200
  return nil unless ok
149
201
 
150
202
  return File.dirname(common) if File.basename(common) == ".git"
151
203
 
204
+ bare_main_worktree(path, common) || toplevel(path)
205
+ end
206
+
207
+ def same_dir?(one, two)
208
+ File.realpath(one.to_s) == File.realpath(two.to_s)
209
+ rescue SystemCallError
210
+ File.expand_path(one.to_s) == File.expand_path(two.to_s)
211
+ end
212
+
213
+ def toplevel(path)
152
214
  top, _err, ok = git(path, "rev-parse", "--show-toplevel")
153
215
  ok ? top : nil
154
216
  end
155
217
 
218
+ def bare_main_worktree(path, common)
219
+ list, _err, ok = git(path, "worktree", "list", "--porcelain")
220
+ return nil unless ok
221
+
222
+ entries = list.split(/\n{2,}/).map do |block|
223
+ block.lines.to_h { |line| line.chomp.split(" ", 2).then { |key, value| [key, value.to_s] } }
224
+ end
225
+ return nil unless entries.first&.key?("bare")
226
+
227
+ head, _err, ok = git(common, "symbolic-ref", "-q", "HEAD")
228
+ main = ok && entries.find { |entry| entry["branch"] == head && File.directory?(entry["worktree"].to_s) }
229
+ main ? main["worktree"] : nil
230
+ end
231
+
156
232
  def head_sha(dir)
157
233
  sha, _err, ok = git(dir, "rev-parse", "HEAD")
158
234
  ok ? sha : nil
@@ -163,10 +239,28 @@ module Harnex
163
239
  ok ? out.lines.map { |line| line[3..].to_s.strip } : []
164
240
  end
165
241
 
242
+ # #90: a worktree lane lives at .harnex/worktrees/NAME on harnex/NAME,
243
+ # unless the repo's .harnex/config.json `lanes` templates say otherwise
244
+ # ({name}, {yyyy}, {mm}, {dd}; worktree_path is relative to the main
245
+ # checkout, so "../{name}" puts lanes beside it).
246
+ def worktree_layout(root, name, now: Time.now)
247
+ templates = Config.load_repo(root).lanes || {}
248
+ fill = lambda do |template|
249
+ template.gsub("{name}", name).gsub("{yyyy}", now.strftime("%Y")).gsub("{mm}", now.strftime("%m"))
250
+ .gsub("{dd}", now.strftime("%d"))
251
+ end
252
+ path = fill.call(templates["worktree_path"] || File.join(WORKTREES_DIR, "{name}"))
253
+ [File.expand_path(path, root), fill.call(templates["branch"] || "#{BRANCH_PREFIX}{name}")]
254
+ rescue Config::ConfigError => e
255
+ raise OptionParser::InvalidArgument, "invalid config #{e.message}"
256
+ end
257
+
166
258
  def create_worktree(root, name)
167
259
  Harnex.ensure_git_excluded(root)
168
- worktree = File.join(root, WORKTREES_DIR, name)
169
- branch = BRANCH_PREFIX + name
260
+ worktree, branch = worktree_layout(root, name)
261
+ _out, _err, valid = git(root, "check-ref-format", "--branch", branch)
262
+ raise OptionParser::InvalidArgument, "lanes.branch gives #{branch.inspect}, which is not a valid branch name" unless valid
263
+
170
264
  _out, _err, exists = git(root, "rev-parse", "--verify", "--quiet", "refs/heads/#{branch}")
171
265
  if exists || File.exist?(worktree)
172
266
  raise OptionParser::InvalidArgument,
@@ -227,8 +321,7 @@ module Harnex
227
321
  closed_at = Time.iso8601((lane["landed_at"] || lane["updated_at"]).to_s)
228
322
  next if closed_at > cutoff
229
323
 
230
- FileUtils.rm_f(path(lane["repo_root"], lane["name"]))
231
- FileUtils.rm_f(File.join(dir, "briefs", "#{Harnex.repo_key(lane['repo_root'])}--#{lane['name']}.md"))
324
+ forget(lane)
232
325
  rescue ArgumentError
233
326
  next
234
327
  end
@@ -247,14 +340,22 @@ module Harnex
247
340
  merged
248
341
  end
249
342
 
343
+ # Inside the checkout: "a/b"; beside it: "../x"; anywhere else: absolute.
250
344
  def relative(root, path)
251
- path.to_s.start_with?("#{root}/") ? path.to_s.delete_prefix("#{root}/") : path.to_s
345
+ return path.to_s.delete_prefix("#{root}/") if path.to_s.start_with?("#{root}/")
346
+
347
+ sibling = "../#{File.basename(path.to_s)}"
348
+ File.expand_path(sibling, root.to_s) == File.expand_path(path.to_s) ? sibling : path.to_s
252
349
  end
253
350
 
254
351
  # ---- briefs ------------------------------------------------------------
255
352
 
353
+ def brief_path(repo_root, name)
354
+ File.join(dir, "briefs", "#{Harnex.repo_key(repo_root)}--#{name}.md")
355
+ end
356
+
256
357
  def write_brief(lane, task)
257
- file = File.join(dir, "briefs", "#{Harnex.repo_key(lane['repo_root'])}--#{lane['name']}.md")
358
+ file = brief_path(lane["repo_root"], lane["name"])
258
359
  footer = if lane["branch"]
259
360
  "You are working in a git worktree on branch #{lane['branch']}; the main checkout is not " \
260
361
  "affected. When you are done, commit your changes on this branch, then reply with a short " \
@@ -275,6 +376,7 @@ module Harnex
275
376
  # done); without, returns [launch_payload, 0]. A launch that fails before
276
377
  # any result (bad flag, busy id, no tmux) raises with harnex's message.
277
378
  def launch(lane, harnex_command, context_args:, child_args: [], wait: true, timeout: DEFAULT_TIMEOUT, detach: false)
379
+ before = lane.slice(*RELAUNCH_KEYS)
278
380
  lane["launched_at"] = now
279
381
  lane["state"] = "running"
280
382
  lane.delete("after_seq")
@@ -283,7 +385,8 @@ module Harnex
283
385
 
284
386
  argv = harnex_command + ["run", "pi", "--id", lane["name"]]
285
387
  argv += detach ? ["--detach"] : ["--tmux", lane["name"]]
286
- argv += ["--cwd", lane["cwd"], "--root", lane["run_root"], "--auto-stop", "--intent", lane["kind"]]
388
+ argv += ["--cwd", lane["cwd"], "--root", lane["run_root"], "--auto-stop", "--intent", lane["kind"],
389
+ "--phase", lane["phase"] || lane["kind"]]
287
390
  argv += context_args
288
391
  argv += ["--model", lane["requested_model"]] if lane["requested_model"]
289
392
  argv += ["--effort", lane["effort"]] if lane["effort"]
@@ -292,9 +395,8 @@ module Harnex
292
395
  stdout, stderr, status = Open3.capture3(*argv)
293
396
  payload = last_json(stdout)
294
397
  if payload.empty?
295
- lane["state"] = "failed"
296
- lane["last_error"] = stderr.strip.lines.last.to_s.strip
297
- save(lane)
398
+ # 124: the worker did not register in time and may still come up.
399
+ status.exitstatus == Waiter::DONE_EXIT_TIMEOUT ? fail_launch(lane, stderr) : unlaunch(lane, before)
298
400
  raise LaunchError, "harnex could not start lane #{lane['name']}: #{stderr.strip.lines.last(3).join.strip}"
299
401
  end
300
402
  return [payload, 0] unless wait
@@ -302,6 +404,24 @@ module Harnex
302
404
  [payload["turn_result"] || payload, status.exitstatus]
303
405
  end
304
406
 
407
+ RELAUNCH_KEYS = %w[state launched_at after_seq last_error].freeze
408
+
409
+ # A launch refused before the worker started (a bad flag, repo phase
410
+ # policy, a busy id) leaves no trace: a new lane's record goes, and a
411
+ # resumed lane keeps what its last turn left.
412
+ def unlaunch(lane, before)
413
+ return forget(lane) if lane["turns"].to_i.zero?
414
+
415
+ RELAUNCH_KEYS.each { |key| before.key?(key) ? lane[key] = before[key] : lane.delete(key) }
416
+ save(lane)
417
+ end
418
+
419
+ def fail_launch(lane, stderr)
420
+ lane["state"] = "failed"
421
+ lane["last_error"] = stderr.strip.lines.last.to_s.strip
422
+ save(lane)
423
+ end
424
+
305
425
  def wait(lane, harnex_command, timeout: DEFAULT_TIMEOUT)
306
426
  argv = harnex_command + ["wait", "--id", lane["name"], "--repo", lane["run_root"], "--until", "done",
307
427
  "--since", lane["launched_at"]]
@@ -384,7 +504,7 @@ module Harnex
384
504
  lane["settled_at"] = now
385
505
  # Kept so a later `harnex result NAME` can show the result again.
386
506
  lane["last_turn"] = turn.slice("final_message", "final_message_path", "final_message_truncated", "model",
387
- "outcome_class", "status", "last_error", "end_sha")
507
+ "outcome_class", "status", "last_error", "end_sha", "earlier_replies")
388
508
  save(lane)
389
509
  end
390
510
 
@@ -401,13 +521,79 @@ module Harnex
401
521
 
402
522
  def render(lane, turn, exit_code)
403
523
  lines = []
404
- message = final_message(turn)
524
+ message = with_earlier_replies(final_message(turn), turn)
405
525
  lines << message.rstrip << "" unless message.strip.empty? || exit_code == Waiter::DONE_EXIT_TIMEOUT
406
526
  lines << "── #{lane['name']}: #{headline(lane, turn, exit_code)}"
407
- lines.concat(details(lane, exit_code).map { |line| " #{line}" })
527
+ lines.concat((interjection_lines(lane) + details(lane, exit_code)).map { |line| " #{line}" })
408
528
  lines.join("\n")
409
529
  end
410
530
 
531
+ # An answered ask prints only its answer, plus what else reached the
532
+ # worker (#92).
533
+ def render_answer(lane, turn)
534
+ text = with_earlier_replies(final_message(turn), turn)
535
+ notes = interjection_lines(lane)
536
+ return text if notes.empty?
537
+
538
+ [text.rstrip, "", "── #{lane['name']}: #{notes.first}", *notes.drop(1).map { |line| " #{line}" }].join("\n")
539
+ end
540
+
541
+ # #92: when a later message reached the worker after it had answered,
542
+ # the turn's result is its answer to that message; the answers it
543
+ # replaced (the original task's report among them) come first.
544
+ def with_earlier_replies(message, turn)
545
+ earlier = Array(turn["earlier_replies"]).map(&:to_s).reject { |text| text.strip.empty? }
546
+ return message if earlier.empty? || message.strip.empty?
547
+
548
+ parts = earlier.map { |text| "[earlier reply this turn, before another message reached the worker]\n#{text.rstrip}" }
549
+ (parts + ["[latest reply]\n#{message.rstrip}"]).join("\n\n")
550
+ end
551
+
552
+ # #92: messages that reached the worker from anyone but the lane's own
553
+ # owner and its `harnex reply` (someone's `harnex send` from another
554
+ # terminal or session), since the lane's latest launch.
555
+ def interjections(lane)
556
+ events(lane).select do |event|
557
+ event["type"] == "send" && event["from"].is_a?(Hash) && after?(event["ts"], lane["launched_at"]) &&
558
+ !own_message?(lane, event["from"])
559
+ end
560
+ end
561
+
562
+ def own_message?(lane, from)
563
+ owner = lane["owner"].to_s
564
+ from["owner"].to_s == owner && (!owner.empty? || from["via"] == "reply")
565
+ end
566
+
567
+ def interjection_lines(lane)
568
+ found = interjections(lane)
569
+ return [] if found.empty?
570
+
571
+ count = found.length == 1 ? "1 message" : "#{found.length} messages"
572
+ ["interjected: #{count} reached the worker from outside this lane " \
573
+ "(the reply above may answer #{found.length == 1 ? 'it' : 'them'}; all: `harnex events --id #{lane['name']}`):"] +
574
+ found.last(5).map { |event| " #{clock(event['ts'])} #{sender_label(event['from'])}: #{preview(event)}" }
575
+ end
576
+
577
+ def sender_label(from)
578
+ who = if from["session"] then "harnex session #{from['session']}"
579
+ elsif from["owner"] then "session #{from['owner'][0, 8]}"
580
+ else "a terminal"
581
+ end
582
+ "from #{who} (harnex #{from['via'] || 'send'})"
583
+ end
584
+
585
+ def preview(event)
586
+ text = event["msg"].to_s.sub(/\A\[harnex [^\]]*\]\s*/, "").gsub(/\s+/, " ").strip
587
+ cut = text.length > 160 || event["msg_truncated"]
588
+ "\"#{text[0, 160]}#{cut ? '…' : ''}\""
589
+ end
590
+
591
+ def clock(ts)
592
+ Time.iso8601(ts.to_s).localtime.strftime("%H:%M:%S")
593
+ rescue ArgumentError
594
+ ts.to_s
595
+ end
596
+
411
597
  def headline(lane, turn, exit_code)
412
598
  model = turn["model"] || lane["model"] || lane["requested_model"]
413
599
  parts = []
@@ -430,6 +616,9 @@ module Harnex
430
616
  dir = lane["worktree"] || lane["cwd"]
431
617
  return nil unless lane["base_sha"] && File.directory?(dir.to_s)
432
618
 
619
+ shas = lane["branch"] ? nil : worker_commits(lane)
620
+ return worker_change_summary(lane, dir, shas) if shas
621
+
433
622
  count, _err, ok = git(dir, "rev-list", "--count", "#{lane['base_sha']}..HEAD")
434
623
  return nil unless ok
435
624
 
@@ -438,6 +627,47 @@ module Harnex
438
627
  stat.empty? ? commits : "#{commits}, #{compact_stat(stat)}"
439
628
  end
440
629
 
630
+ # #91: a lane working in place shares HEAD with whoever else commits
631
+ # there, so count only the commits the worker made (worker_commits
632
+ # events). nil when the worker's harnex did not track them.
633
+ def worker_commits(lane)
634
+ found = events(lane).select { |event| after?(event["ts"], lane["created_at"]) }
635
+ return nil unless found.any? { |event| event["type"] == "git" && event["tracks_worker_commits"] }
636
+
637
+ found.select { |event| event["type"] == "worker_commits" }.flat_map { |event| Array(event["shas"]) }
638
+ end
639
+
640
+ def worker_change_summary(lane, dir, shas)
641
+ moved, _err, ok = git(dir, "rev-list", "#{lane['base_sha']}..HEAD")
642
+ return nil unless ok
643
+
644
+ history = moved.split
645
+ mine = history & shas
646
+ text = "#{mine.length} commit#{mine.length == 1 ? '' : 's'}"
647
+ stat = mine.empty? ? nil : commits_stat(dir, mine)
648
+ text += ", #{stat}" if stat
649
+ others = history.length - mine.length
650
+ text += " (HEAD also moved #{others} commit#{others == 1 ? '' : 's'} by others)" if others.positive?
651
+ text
652
+ end
653
+
654
+ def commits_stat(dir, shas)
655
+ out, _err, ok = git(dir, "show", "--numstat", "--format=", *shas)
656
+ return nil unless ok
657
+
658
+ files = {}
659
+ added = removed = 0
660
+ out.each_line do |line|
661
+ plus, minus, path = line.chomp.split("\t", 3)
662
+ next unless path
663
+
664
+ files[path] = true
665
+ added += plus.to_i
666
+ removed += minus.to_i
667
+ end
668
+ "#{files.size} file#{files.size == 1 ? '' : 's'} (+#{added} -#{removed})"
669
+ end
670
+
441
671
  def compact_stat(stat)
442
672
  files = stat[/(\d+) files? changed/, 1]
443
673
  added = stat[/(\d+) insertions?/, 1]
data/lib/harnex/mcp.rb CHANGED
@@ -41,6 +41,7 @@ module Harnex
41
41
  "model" => MODEL, "effort" => EFFORT,
42
42
  "fallback_model" => { "type" => "string", "description" => "Ask this model instead if the first is rate limited" },
43
43
  "write" => { "type" => "boolean", "description" => "Let the worker edit files and run commands (default false)" },
44
+ "name" => { "type" => "string", "description" => "Lane name (default: pi-ask-<random>)" },
44
45
  "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
45
46
  } },
46
47
  "annotations" => { "readOnlyHint" => false, "openWorldHint" => true }
@@ -56,7 +57,7 @@ module Harnex
56
57
  "inputSchema" => { "type" => "object", "required" => ["task"], "properties" => {
57
58
  "task" => { "type" => "string", "description" => "Self-contained brief: files, expected behavior, how to verify (e.g. the test command)" },
58
59
  "worktree" => { "type" => "string", "description" => "Lane name; work in a new git worktree on branch harnex/NAME (recommended for edits)" },
59
- "name" => { "type" => "string", "description" => "Lane name when not using a worktree (default: from the task)" },
60
+ "name" => { "type" => "string", "description" => "Lane name when not using a worktree (default: the cwd worktree's name, else from the task)" },
60
61
  "wait" => { "type" => "boolean", "description" => "Wait for the result (default true); false starts the lane and returns at once" },
61
62
  "model" => MODEL, "effort" => EFFORT, "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
62
63
  } },
@@ -250,10 +251,11 @@ module Harnex
250
251
  case tool
251
252
  when "delegate"
252
253
  if args["worktree"].to_s.empty? && args["name"].to_s.empty?
253
- root = Lanes.main_root(work_dir(args)) || work_dir(args)
254
- args["name"] = Lanes.generate_name(root, args["task"] || args["prompt"] || args["brief"])
254
+ dir = work_dir(args)
255
+ args["name"] = Lanes.generate_name(Lanes.main_root(dir) || dir, args["task"] || args["prompt"] || args["brief"], cwd: dir)
255
256
  end
256
257
  when "ask"
258
+ args["id"] = args["name"] unless args["name"].to_s.strip.empty?
257
259
  args["id"] ||= "pi-ask-#{SecureRandom.hex(2)}"
258
260
  end
259
261
  args
@@ -78,7 +78,8 @@ module Harnex
78
78
  text: payload[:text],
79
79
  submit: payload[:submit],
80
80
  enter_only: payload[:enter_only],
81
- force: payload[:force]
81
+ force: payload[:force],
82
+ from: payload[:from]
82
83
  )
83
84
  http_code = result.delete(:http_status) || 200
84
85
  json(client, http_code, result)
@@ -136,6 +137,15 @@ module Harnex
136
137
  context
137
138
  end
138
139
 
140
+ # Who sent a message (#92), as `harnex send` reports it: bounded strings.
141
+ def sender(value)
142
+ return nil unless value.is_a?(Hash)
143
+
144
+ value.slice("via", "session", "owner").filter_map do |key, text|
145
+ [key, text.to_s[0, 120]] unless text.to_s.strip.empty?
146
+ end.to_h
147
+ end
148
+
139
149
  def parse_send_body(headers, body)
140
150
  if headers["content-type"].to_s.include?("application/json")
141
151
  parsed = JSON.parse(body.empty? ? "{}" : body)
@@ -145,7 +155,8 @@ module Harnex
145
155
  text: parsed["text"].to_s,
146
156
  submit: parsed.fetch("submit", true),
147
157
  enter_only: parsed.fetch("enter_only", false),
148
- force: parsed.fetch("force", false)
158
+ force: parsed.fetch("force", false),
159
+ from: sender(parsed["from"])
149
160
  }
150
161
  else
151
162
  {
@@ -32,13 +32,14 @@ module Harnex
32
32
  @thread&.kill
33
33
  end
34
34
 
35
- def enqueue(text:, submit:, enter_only:, force: false)
35
+ def enqueue(text:, submit:, enter_only:, force: false, from: nil)
36
36
  msg = Message.new(
37
37
  id: SecureRandom.hex(8),
38
38
  text: text,
39
39
  submit: submit,
40
40
  enter_only: enter_only,
41
41
  force: force,
42
+ from: from,
42
43
  queued_at: Time.now,
43
44
  status: :queued
44
45
  )
@@ -139,6 +140,7 @@ module Harnex
139
140
  def deliver_now(msg, follow_up: false)
140
141
  options = { text: msg.text, submit: msg.submit, enter_only: msg.enter_only, force: msg.force }
141
142
  options[:follow_up] = true if follow_up
143
+ options[:from] = msg.from if msg.from
142
144
  result = @session.inject_via_adapter(**options)
143
145
  msg.status = :delivered
144
146
  msg.delivered_at = Time.now
@@ -1,6 +1,6 @@
1
1
  module Harnex
2
2
  Message = Struct.new(:id, :text, :submit, :enter_only, :force, :queued_at, :status, :delivered_at, :error,
3
- :after_seq, :delivery, keyword_init: true) do
3
+ :after_seq, :delivery, :from, keyword_init: true) do
4
4
  def to_h
5
5
  {
6
6
  id: id,