harnex 0.4.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4d7c194ba2ba10ee075e6e53aa3eaf291cbfc08eca1d89cd291955834230c01
4
- data.tar.gz: e3bafc087ae74a484be3ee121cce0d8f351c6b381ce244d5da032e48c85da4db
3
+ metadata.gz: 2b38fbca70a1ec608f414f362bb16bff85bdb5c279a5c562c4dc7048fa6acb41
4
+ data.tar.gz: 7a9048de3efb9461489ab0678683e0d245e60e2ac3a1dd7e617d868fce927a51
5
5
  SHA512:
6
- metadata.gz: e3737c2aa49fb223c75cfc0d997a52bb72029b4c1a7a11affdd05a16c685e4edc42384ff0cfc03e63e4b4abcc9228c7f1d37c408c074538392c75d13be4e1e5e
7
- data.tar.gz: 0b096fb9d95f22b812fc43fc69b4f8546e090d76c669383b8d71b34a048aa8eed46510f7ce9be27e0da3eab449a610c13fd6a338d8e1c202649d235df7052154
6
+ metadata.gz: a222bd5df7a1e02e7b6cebb2e0a63649b4433baef17be35a3dd03b4128e9b406af52ae71e9c063a6912dbb9af04b187072a693271fb72f24e4d61de462a7bf1c
7
+ data.tar.gz: eb2ca6564d079b0e162e72e9d07d01239ba2ce0c5b118fbd4360671d555c452571b2c64cb0f5a4be3c0b23c13e57b606d7ca98632d345f7570d5c69caacc9ad8
@@ -39,6 +39,10 @@ module Harnex
39
39
  }
40
40
  end
41
41
 
42
+ def parse_session_summary(_transcript_tail)
43
+ {}
44
+ end
45
+
42
46
  def send_wait_seconds(submit:, enter_only:)
43
47
  0.0
44
48
  end
@@ -56,6 +56,25 @@ module Harnex
56
56
  end
57
57
  end
58
58
 
59
+ def parse_session_summary(transcript_tail)
60
+ summary = empty_session_summary
61
+ text = transcript_tail.to_s
62
+
63
+ if (match = text.match(/Token usage:\s+total=([\d,]+)\s+input=([\d,]+)(?:\s+\(\+\s+([\d,]+)\s+cached\))?\s+output=([\d,]+)(?:\s+\(reasoning\s+([\d,]+)\))?/))
64
+ summary[:total_tokens] = parse_token_count(match[1])
65
+ summary[:input_tokens] = parse_token_count(match[2])
66
+ summary[:cached_tokens] = parse_token_count(match[3])
67
+ summary[:output_tokens] = parse_token_count(match[4])
68
+ summary[:reasoning_tokens] = parse_token_count(match[5])
69
+ end
70
+
71
+ if (match = text.match(/codex resume\s+([0-9a-f-]{36})/))
72
+ summary[:agent_session_id] = match[1]
73
+ end
74
+
75
+ summary
76
+ end
77
+
59
78
  def send_wait_seconds(submit:, enter_only:)
60
79
  return 0.0 unless submit
61
80
  return 0.0 if enter_only
@@ -101,6 +120,23 @@ module Harnex
101
120
 
102
121
  protected
103
122
 
123
+ def empty_session_summary
124
+ {
125
+ input_tokens: nil,
126
+ output_tokens: nil,
127
+ reasoning_tokens: nil,
128
+ cached_tokens: nil,
129
+ total_tokens: nil,
130
+ agent_session_id: nil
131
+ }
132
+ end
133
+
134
+ def parse_token_count(value)
135
+ return nil if value.nil?
136
+
137
+ Integer(value.delete(","))
138
+ end
139
+
104
140
  def submit_delay_ms(text)
105
141
  extra = (text.to_s.bytesize / 1024.0 * SUBMIT_DELAY_PER_KB_MS).ceil
106
142
  SUBMIT_DELAY_MS + extra
@@ -7,11 +7,12 @@ module Harnex
7
7
  DEFAULT_TIMEOUT = 5.0
8
8
  KNOWN_FLAGS = %w[
9
9
  --id --description --detach --tmux --host --port --watch --watch-file
10
- --stall-after --max-resumes --preset --context --timeout --inbox-ttl --help
10
+ --stall-after --max-resumes --preset --context --meta --summary-out
11
+ --timeout --inbox-ttl --help
11
12
  ].freeze
12
13
  VALUE_FLAGS = %w[
13
14
  --id --description --host --port --watch --watch-file --stall-after
14
- --max-resumes --preset --context --timeout --inbox-ttl
15
+ --max-resumes --preset --context --meta --summary-out --timeout --inbox-ttl
15
16
  ].freeze
16
17
 
17
18
  def self.usage(program_name = "harnex run")
@@ -31,6 +32,8 @@ module Harnex
31
32
  --preset NAME Watch preset: impl, plan, gate (requires --watch)
32
33
  --watch-file PATH Auto-send a file-change hook on modification
33
34
  --context TEXT Inject as the initial prompt (prepends session header)
35
+ --meta JSON Attach parsed JSON metadata to the started event
36
+ --summary-out PATH Append dispatch telemetry summary JSONL to PATH
34
37
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
35
38
  --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
36
39
  -h, --help Show this help
@@ -60,6 +63,8 @@ module Harnex
60
63
  preset: nil,
61
64
  watch: nil,
62
65
  context: nil,
66
+ meta: nil,
67
+ summary_out: nil,
63
68
  detach: false,
64
69
  tmux: false,
65
70
  tmux_name: nil,
@@ -79,6 +84,7 @@ module Harnex
79
84
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
80
85
 
81
86
  repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
87
+ @options[:summary_out] = resolve_summary_out(repo_root)
82
88
  @options[:id] ||= Harnex.generate_id(repo_root)
83
89
  validate_unique_id!(repo_root)
84
90
  effective_child_args = apply_context(child_args)
@@ -137,6 +143,8 @@ module Harnex
137
143
  tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
138
144
  tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
139
145
  tmux_cmd += ["--context", @options[:context]] if @options[:context]
146
+ tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
147
+ tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
140
148
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
141
149
  tmux_cmd += ["--"] + child_args unless child_args.empty?
142
150
 
@@ -239,6 +247,8 @@ module Harnex
239
247
  id: @options[:id],
240
248
  watch: watch,
241
249
  description: @options[:description],
250
+ meta: @options[:meta],
251
+ summary_out: @options[:summary_out],
242
252
  inbox_ttl: @options[:inbox_ttl]
243
253
  )
244
254
  end
@@ -385,6 +395,16 @@ module Harnex
385
395
  @options[:context] = required_option_value(arg, argv[index])
386
396
  when /\A--context=(.+)\z/
387
397
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
398
+ when "--meta"
399
+ index += 1
400
+ @options[:meta] = parse_meta(required_option_value(arg, argv[index]))
401
+ when /\A--meta=(.+)\z/
402
+ @options[:meta] = parse_meta(required_option_value("--meta", Regexp.last_match(1)))
403
+ when "--summary-out"
404
+ index += 1
405
+ @options[:summary_out] = required_option_value(arg, argv[index])
406
+ when /\A--summary-out=(.+)\z/
407
+ @options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
388
408
  when "--timeout"
389
409
  index += 1
390
410
  @options[:timeout] = Float(required_option_value(arg, argv[index]))
@@ -440,7 +460,7 @@ module Harnex
440
460
  nil
441
461
  when *VALUE_FLAGS
442
462
  index += 1
443
- when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|timeout|inbox-ttl)=/
463
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|timeout|inbox-ttl)=/
444
464
  nil
445
465
  when /\A--preset=/
446
466
  nil
@@ -458,7 +478,8 @@ module Harnex
458
478
  arg == "-h" ||
459
479
  arg.start_with?(
460
480
  "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
461
- "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--timeout=", "--inbox-ttl="
481
+ "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
482
+ "--timeout=", "--inbox-ttl="
462
483
  )
463
484
  end
464
485
 
@@ -489,6 +510,22 @@ module Harnex
489
510
  raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
490
511
  end
491
512
 
513
+ def parse_meta(value)
514
+ parsed = JSON.parse(value)
515
+ return parsed if parsed.is_a?(Hash)
516
+
517
+ raise OptionParser::InvalidOption, "--meta must be a JSON object"
518
+ rescue JSON::ParserError => e
519
+ raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
520
+ end
521
+
522
+ def resolve_summary_out(repo_root)
523
+ configured = @options[:summary_out]
524
+ return Harnex.default_summary_out_path(repo_root) if configured.nil?
525
+
526
+ File.expand_path(configured, repo_root)
527
+ end
528
+
492
529
  def default_inbox_ttl
493
530
  value = ENV["HARNEX_INBOX_TTL"]
494
531
  return Inbox::DEFAULT_TTL.to_f if value.nil? || value.strip.empty?
data/lib/harnex/core.rb CHANGED
@@ -64,6 +64,67 @@ module Harnex
64
64
  seconds
65
65
  end
66
66
 
67
+ def harness_version
68
+ VERSION
69
+ end
70
+
71
+ def host_info
72
+ {
73
+ host: Socket.gethostname,
74
+ platform: RUBY_PLATFORM
75
+ }
76
+ rescue StandardError
77
+ {
78
+ host: nil,
79
+ platform: RUBY_PLATFORM
80
+ }
81
+ end
82
+
83
+ def strip_ansi(text)
84
+ text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
85
+ end
86
+
87
+ def default_summary_out_path(repo_root)
88
+ koder_dir = File.join(repo_root.to_s, "koder")
89
+ return nil unless File.directory?(koder_dir)
90
+
91
+ File.join(koder_dir, "DISPATCH.jsonl")
92
+ end
93
+
94
+ def git_capture_start(repo_root)
95
+ sha = git_output(repo_root, "rev-parse", "HEAD")
96
+ branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
97
+ return {} if sha.empty? || branch.empty?
98
+
99
+ {
100
+ sha: sha,
101
+ branch: branch
102
+ }
103
+ rescue StandardError
104
+ {}
105
+ end
106
+
107
+ def git_capture_end(repo_root, start_sha)
108
+ start_sha = start_sha.to_s.strip
109
+ return {} if start_sha.empty?
110
+
111
+ end_sha = git_output(repo_root, "rev-parse", "HEAD")
112
+ range = "#{start_sha}..#{end_sha}"
113
+ shortstat = git_output(repo_root, "diff", "--shortstat", range)
114
+ commits = Integer(git_output(repo_root, "rev-list", "--count", range))
115
+ stats = parse_git_shortstat(shortstat)
116
+
117
+ {
118
+ sha: end_sha,
119
+ loc_added: stats.fetch(:loc_added),
120
+ loc_removed: stats.fetch(:loc_removed),
121
+ files_changed: stats.fetch(:files_changed),
122
+ commits: commits
123
+ }
124
+ rescue StandardError
125
+ {}
126
+ end
127
+
67
128
  def repo_key(repo_root)
68
129
  Digest::SHA256.hexdigest(repo_root)[0, 16]
69
130
  end
@@ -316,4 +377,19 @@ module Harnex
316
377
  debounce_seconds: WATCH_DEBOUNCE_SECONDS
317
378
  )
318
379
  end
380
+
381
+ def git_output(repo_root, *args)
382
+ stdout, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args)
383
+ raise "git #{args.join(' ')} failed" unless status.success?
384
+
385
+ stdout.strip
386
+ end
387
+
388
+ def parse_git_shortstat(text)
389
+ {
390
+ files_changed: text.to_s[/(\d+)\s+files?\s+changed/, 1].to_i,
391
+ loc_added: text.to_s[/(\d+)\s+insertions?\(\+\)/, 1].to_i,
392
+ loc_removed: text.to_s[/(\d+)\s+deletions?\(-\)/, 1].to_i
393
+ }
394
+ end
319
395
  end
@@ -5,10 +5,41 @@ require "pty"
5
5
  module Harnex
6
6
  class Session
7
7
  OUTPUT_BUFFER_LIMIT = 64 * 1024
8
+ TRANSCRIPT_TAIL_BYTES = 16 * 1024
9
+ USAGE_FIELDS = %i[
10
+ input_tokens output_tokens reasoning_tokens cached_tokens total_tokens agent_session_id
11
+ ].freeze
12
+ class EventCounters
13
+ def initialize
14
+ @counts = {
15
+ stalls: 0,
16
+ force_resumes: 0,
17
+ disconnections: 0,
18
+ compactions: 0
19
+ }
20
+ end
21
+
22
+ def record(type)
23
+ case type.to_s
24
+ when "log_idle"
25
+ @counts[:stalls] += 1
26
+ when "resume"
27
+ @counts[:force_resumes] += 1
28
+ when "disconnect", "disconnection"
29
+ @counts[:disconnections] += 1
30
+ when "compaction"
31
+ @counts[:compactions] += 1
32
+ end
33
+ end
8
34
 
9
- attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path, :events_log_path
35
+ def snapshot
36
+ @counts.dup
37
+ end
38
+ end
10
39
 
11
- def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, inbox_ttl: Inbox::DEFAULT_TTL)
40
+ attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path
41
+
42
+ def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL)
12
43
  @adapter = adapter
13
44
  @command = command
14
45
  @repo_root = repo_root
@@ -17,6 +48,9 @@ module Harnex
17
48
  @watch = watch
18
49
  @description = description.to_s.strip
19
50
  @description = nil if @description.empty?
51
+ @meta = meta
52
+ @summary_out = summary_out.to_s.strip
53
+ @summary_out = nil if @summary_out.empty?
20
54
  @registry_path = Harnex.registry_path(repo_root, @id)
21
55
  @output_log_path = Harnex.output_log_path(repo_root, @id)
22
56
  @events_log_path = Harnex.events_log_path(repo_root, @id)
@@ -34,6 +68,12 @@ module Harnex
34
68
  @output_log = nil
35
69
  @events_log = nil
36
70
  @events_log_seq = 0
71
+ @event_counters = EventCounters.new
72
+ @git_start = {}
73
+ @git_end = {}
74
+ @usage_summary = {}
75
+ @ended_at = nil
76
+ @exit_reason = nil
37
77
  @writer = nil
38
78
  @pid = nil
39
79
  @term_signal = nil
@@ -67,7 +107,8 @@ module Harnex
67
107
  prepare_events_log
68
108
  @reader, @writer, @pid = PTY.spawn(child_env, *command)
69
109
  @writer.sync = true
70
- emit_event("started", pid: @pid)
110
+ emit_started_event
111
+ emit_git_start_event
71
112
 
72
113
  install_signal_handlers
73
114
  sync_window_size
@@ -84,9 +125,15 @@ module Harnex
84
125
  _, status = Process.wait2(pid)
85
126
  @term_signal = status.signaled? ? status.termsig : nil
86
127
  @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
87
- emit_exit_event
128
+ @ended_at = Time.now
88
129
 
89
130
  output_thread.join(1)
131
+ emit_session_end_telemetry
132
+ @exit_reason = classify_exit
133
+ summary_record = build_summary_record
134
+ append_summary_record(summary_record)
135
+ emit_summary_event
136
+ emit_exit_event
90
137
  input_thread&.kill
91
138
  watch_thread&.kill
92
139
  @exit_code
@@ -390,13 +437,170 @@ module Harnex
390
437
  emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
391
438
  end
392
439
 
440
+ def emit_started_event
441
+ payload = { pid: @pid }
442
+ payload[:meta] = meta if meta
443
+ emit_event("started", **payload)
444
+ end
445
+
446
+ def emit_git_start_event
447
+ @git_start = Harnex.git_capture_start(repo_root)
448
+ return if @git_start.empty?
449
+
450
+ emit_event("git", phase: "start", sha: @git_start[:sha], branch: @git_start[:branch])
451
+ end
452
+
453
+ def emit_session_end_telemetry
454
+ @usage_summary = normalized_usage_summary(adapter.parse_session_summary(transcript_tail))
455
+ emit_event("usage", **@usage_summary)
456
+
457
+ @git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
458
+ return if @git_end.empty?
459
+
460
+ emit_event(
461
+ "git",
462
+ phase: "end",
463
+ sha: @git_end[:sha],
464
+ loc_added: @git_end[:loc_added],
465
+ loc_removed: @git_end[:loc_removed],
466
+ files_changed: @git_end[:files_changed],
467
+ commits: @git_end[:commits]
468
+ )
469
+ end
470
+
471
+ def emit_summary_event
472
+ emit_event("summary", path: summary_out, exit: @exit_reason)
473
+ end
474
+
393
475
  def emit_exit_event
394
476
  payload = { code: @exit_code }
395
477
  payload[:signal] = @term_signal if @term_signal
478
+ payload[:reason] = @exit_reason if @exit_reason
396
479
  emit_event("exited", **payload)
397
480
  end
398
481
 
482
+ def classify_exit
483
+ return "timeout" if @exit_code == 124
484
+ return "failure" unless @exit_code == 0
485
+ return "success" if session_summary_present?
486
+
487
+ "disconnected"
488
+ end
489
+
490
+ def session_summary_present?
491
+ @usage_summary.values.any? { |value| !value.nil? }
492
+ end
493
+
494
+ def build_summary_record
495
+ {
496
+ meta: build_summary_meta,
497
+ predicted: summary_predicted_payload,
498
+ actual: build_summary_actual
499
+ }
500
+ end
501
+
502
+ def build_summary_meta
503
+ info = Harnex.host_info
504
+ passthrough = meta_hash
505
+
506
+ {
507
+ id: id,
508
+ tmux_session: id,
509
+ description: description,
510
+ started_at: @started_at.iso8601,
511
+ ended_at: @ended_at&.iso8601,
512
+ harness: "harnex",
513
+ harness_version: Harnex.harness_version,
514
+ agent: adapter.key,
515
+ agent_version: nil,
516
+ agent_provider: nil,
517
+ agent_deployment: nil,
518
+ host: info[:host],
519
+ platform: info[:platform],
520
+ orchestrator: passthrough["orchestrator"],
521
+ orchestrator_session: passthrough["orchestrator_session"],
522
+ chain_id: passthrough["chain_id"],
523
+ parent_dispatch_id: passthrough["parent_dispatch_id"],
524
+ tier: passthrough["tier"],
525
+ phase: passthrough["phase"],
526
+ issue: passthrough["issue"],
527
+ plan: passthrough["plan"],
528
+ task_brief: passthrough["task_brief"],
529
+ repo: repo_root,
530
+ branch: @git_start[:branch],
531
+ start_sha: @git_start[:sha],
532
+ end_sha: @git_end[:sha]
533
+ }
534
+ end
535
+
536
+ def build_summary_actual
537
+ counters = @event_counters.snapshot
538
+ counters[:disconnections] = [counters[:disconnections], 1].max if @exit_reason == "disconnected"
539
+
540
+ {
541
+ model: meta_hash["model"],
542
+ effort: meta_hash["effort"],
543
+ duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
544
+ input_tokens: @usage_summary[:input_tokens],
545
+ output_tokens: @usage_summary[:output_tokens],
546
+ reasoning_tokens: @usage_summary[:reasoning_tokens],
547
+ cached_tokens: @usage_summary[:cached_tokens],
548
+ cost_usd: nil,
549
+ loc_added: @git_end[:loc_added],
550
+ loc_removed: @git_end[:loc_removed],
551
+ files_changed: @git_end[:files_changed],
552
+ commits: @git_end[:commits],
553
+ exit: @exit_reason,
554
+ stalls: counters[:stalls],
555
+ force_resumes: counters[:force_resumes],
556
+ disconnections: counters[:disconnections],
557
+ compactions: counters[:compactions],
558
+ tests_run: nil,
559
+ tests_passed: nil,
560
+ tests_failed: nil
561
+ }
562
+ end
563
+
564
+ def summary_predicted_payload
565
+ predicted = meta_hash["predicted"]
566
+ predicted.is_a?(Hash) ? predicted : {}
567
+ end
568
+
569
+ def meta_hash
570
+ meta.is_a?(Hash) ? meta : {}
571
+ end
572
+
573
+ def append_summary_record(record)
574
+ return unless summary_out
575
+
576
+ FileUtils.mkdir_p(File.dirname(summary_out))
577
+ File.open(summary_out, "ab") do |file|
578
+ file.write(JSON.generate(record))
579
+ file.write("\n")
580
+ end
581
+ rescue StandardError => e
582
+ warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
583
+ end
584
+
585
+ def normalized_usage_summary(summary)
586
+ summary ||= {}
587
+ USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
588
+ end
589
+
590
+ def transcript_tail
591
+ return "" unless File.file?(output_log_path)
592
+
593
+ File.open(output_log_path, "rb") do |file|
594
+ size = file.size
595
+ file.seek([size - TRANSCRIPT_TAIL_BYTES, 0].max)
596
+ Harnex.strip_ansi(file.read.to_s)
597
+ end
598
+ rescue StandardError
599
+ ""
600
+ end
601
+
399
602
  def emit_event(type, **payload)
603
+ @event_counters.record(type)
400
604
  @events_mutex.synchronize do
401
605
  return unless @events_log
402
606
 
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.4.0"
3
- RELEASE_DATE = "2026-04-30"
2
+ VERSION = "0.5.0"
3
+ RELEASE_DATE = "2026-05-01"
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-29 00:00:00.000000000 Z
11
+ date: 2026-05-01 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
14
14
  adds a control plane for discovery, messaging, and coordination.