hiiro 0.1.360 → 0.1.362

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +24 -17
  4. data/bin/h-app +4 -6
  5. data/bin/h-bg +4 -9
  6. data/bin/h-branch +21 -21
  7. data/bin/h-claude +6 -6
  8. data/bin/h-config +2 -2
  9. data/bin/h-date +48 -0
  10. data/bin/h-notify +92 -257
  11. data/bin/h-pane +96 -137
  12. data/bin/h-pr +16 -10
  13. data/bin/h-project +7 -19
  14. data/bin/h-session +52 -106
  15. data/bin/h-time +48 -0
  16. data/bin/h-title +12 -44
  17. data/bin/h-window +24 -135
  18. data/bin/h-wtree +2 -2
  19. data/docs/README.md +10 -13
  20. data/docs/docker-testing-guide.md +6 -7
  21. data/docs/h-app.md +2 -2
  22. data/docs/h-bg.md +11 -59
  23. data/docs/h-branch.md +3 -4
  24. data/docs/h-claude.md +25 -25
  25. data/docs/h-config.md +4 -5
  26. data/docs/h-notify.md +33 -106
  27. data/docs/h-pane.md +36 -148
  28. data/docs/h-pr.md +2 -3
  29. data/docs/h-project.md +3 -3
  30. data/docs/h-queue.md +24 -24
  31. data/docs/h-service.md +8 -8
  32. data/docs/h-session.md +29 -126
  33. data/docs/h-subtask.md +3 -3
  34. data/docs/h-task.md +16 -28
  35. data/docs/h-title.md +5 -25
  36. data/docs/h-window.md +19 -118
  37. data/docs/h-wtree.md +3 -4
  38. data/docs/h.md +10 -12
  39. data/exe/h +1 -1
  40. data/lib/hiiro/background.rb +17 -14
  41. data/lib/hiiro/branch.rb +24 -3
  42. data/lib/hiiro/db.rb +4 -1
  43. data/lib/hiiro/effects.rb +2 -2
  44. data/lib/hiiro/git/pr.rb +13 -6
  45. data/lib/hiiro/herdr.rb +610 -0
  46. data/lib/hiiro/invocation.rb +16 -19
  47. data/lib/hiiro/options.rb +5 -1
  48. data/lib/hiiro/pinned_pr.rb +16 -3
  49. data/lib/hiiro/pinned_pr_manager.rb +1 -1
  50. data/lib/hiiro/queue.rb +93 -74
  51. data/lib/hiiro/service_manager.rb +54 -80
  52. data/lib/hiiro/tasks.rb +53 -91
  53. data/lib/hiiro/version.rb +1 -1
  54. data/lib/hiiro.rb +5 -6
  55. data/merge +47 -0
  56. data/plugins/notify.rb +2 -7
  57. data/plugins/project.rb +2 -2
  58. metadata +10 -17
  59. data/bin/h-buffer +0 -123
  60. data/bin/h-jumplist +0 -287
  61. data/docs/h-buffer.md +0 -133
  62. data/docs/h-jumplist.md +0 -111
  63. data/lib/hiiro/task_colors.rb +0 -38
  64. data/lib/hiiro/tmux/buffer.rb +0 -63
  65. data/lib/hiiro/tmux/buffers.rb +0 -58
  66. data/lib/hiiro/tmux/pane.rb +0 -122
  67. data/lib/hiiro/tmux/panes.rb +0 -81
  68. data/lib/hiiro/tmux/session.rb +0 -112
  69. data/lib/hiiro/tmux/sessions.rb +0 -61
  70. data/lib/hiiro/tmux/window.rb +0 -94
  71. data/lib/hiiro/tmux/windows.rb +0 -73
  72. data/lib/hiiro/tmux.rb +0 -384
data/bin/h-notify CHANGED
@@ -1,52 +1,29 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require "hiiro"
4
- require "fileutils"
5
- require "yaml"
3
+ require 'fileutils'
4
+ require 'hiiro'
5
+ require 'yaml'
6
6
 
7
7
  TYPE_PRESETS = {
8
- 'success' => { prefix: '[OK]', sound: 'Glass', title: 'Success' },
9
- 'error' => { prefix: '[ERR]', sound: 'Basso', title: 'Error' },
10
- 'info' => { prefix: '[INFO]', sound: 'Pop', title: 'Info' },
11
- 'warning' => { prefix: '[WARN]', sound: 'Purr', title: 'Warning' },
12
- }
8
+ 'success' => { prefix: '[OK]', title: 'Success', sound: 'done' },
9
+ 'error' => { prefix: '[ERR]', title: 'Error', sound: 'request' },
10
+ 'info' => { prefix: '[INFO]', title: 'Info', sound: 'none' },
11
+ 'warning' => { prefix: '[WARN]', title: 'Warning', sound: 'request' },
12
+ }.freeze
13
13
 
14
14
  LOG_FILE = Hiiro::Config.data_path('notify_log.yml')
15
-
16
- TMUX_CONF = File.join(Dir.home, '.tmux.conf')
17
- NOTIFY_CONF = File.join(Dir.home, '.config', 'tmux', 'h-notify.tmux.conf')
18
15
  CLAUDE_SETTINGS = File.join(Dir.home, '.claude', 'settings.json')
19
-
20
- NOTIFY_TMUX_HOOKS = %w[after-kill-pane window-unlinked session-closed].freeze
21
-
22
- NOTIFY_TMUX_CONF_CONTENT = <<~'CONF'
23
- # --- Notify hooks (clean up notifications when panes/windows/sessions close) ---
24
- set-hook -g after-kill-pane "run-shell -b 'h notify remove_pane \#{hook_pane_id}'"
25
- set-hook -g session-closed "run-shell -b 'h notify remove_session \#{hook_session_name}'"
26
- # set-hook -g window-unlinked "run-shell -b 'h notify remove_window \#{hook_window_id}'"
27
-
28
- # --- Notify keybinding (prefix + N to open notification menu) ---
29
- bind-key N run-shell "h notify menu"
30
- CONF
31
-
32
- CLAUDE_NOTIFICATION_CMD = %q(MSG=$(cat | jq -r '.message // "Needs input"'); h alert -t 'Claude Code' -m "$MSG" -s k -c "echo switchc -t '$TMUX_PANE' | pbcopy"; h notify push -t info "$MSG")
33
- CLAUDE_STOP_CMD = %q(h alert -t 'Claude Code' -m 'Work completed' -s b -c "echo switchc -t '$TMUX_PANE' | pbcopy"; h notify push -t success 'Work completed')
16
+ CLAUDE_NOTIFICATION_CMD = %q(MSG=$(cat | jq -r '.message // "Needs input"'); h notify push -t info "$MSG")
17
+ CLAUDE_STOP_CMD = %q(h notify push -t success 'Work completed')
34
18
 
35
19
  def read_log
36
20
  return [] unless File.exist?(LOG_FILE)
21
+
37
22
  data = YAML.safe_load(File.read(LOG_FILE))
38
- if data.is_a?(Hash)
39
- backup = "#{LOG_FILE}.bak.#{Time.now.strftime('%Y%m%d%H%M%S')}"
40
- FileUtils.cp(LOG_FILE, backup)
41
- $stderr.puts "h notify: migrated log to flat format (backup: #{backup})"
42
- migrated = data.values.flatten.compact.sort_by { |e| -(e['timestamp'] || 0) }
43
- write_log(migrated)
44
- migrated
45
- elsif data.is_a?(Array)
46
- data
47
- else
48
- []
49
- end
23
+ return data if data.is_a?(Array)
24
+ return [] unless data.is_a?(Hash)
25
+
26
+ data.values.flatten.compact.sort_by { |entry| -(entry['timestamp'] || 0) }
50
27
  rescue
51
28
  []
52
29
  end
@@ -56,304 +33,162 @@ def write_log(data)
56
33
  File.write(LOG_FILE, data.to_yaml)
57
34
  end
58
35
 
59
- def current_pane
60
- # Prefer $TMUX_PANE env var — it's reliably set even in hook subprocesses
61
- env_id = ENV['TMUX_PANE'].to_s
62
- if !env_id.empty?
63
- Hiiro::Tmux::Panes.fetch(all: true).find_by_id(env_id)
64
- else
65
- Hiiro::Tmux::Pane.current
66
- end
67
- end
68
-
69
- def pane_exists?(pane_id)
70
- Hiiro::Tmux::Panes.fetch(all: true).find_by_id(pane_id)
71
- end
72
-
73
36
  Hiiro.run(*ARGV, tasks: true) do
74
37
  @opts = Hiiro::Options.parse(args) do
75
38
  option(:type, short: 't', desc: 'Notification type (success, error, info, warning)', default: 'info')
76
39
  end
77
-
78
40
  @preset = TYPE_PRESETS[@opts.type] || TYPE_PRESETS['info']
79
41
 
42
+ live_panes = -> { herdr_client.panes(all: true) }
43
+
80
44
  add_subcmd(:push) do
81
45
  message = @opts.args.join(' ')
82
46
  if message.empty?
83
- puts "Usage: h notify push [-t TYPE] \"message\""
84
- next
85
- end
86
-
87
- pane = current_pane
88
- session = pane&.session_name || Hiiro::Tmux::Session.current&.name
89
-
90
- unless pane && session
91
- $stderr.puts "h notify push: not in a tmux session"
47
+ puts 'Usage: h notify push [-t TYPE] "message"'
92
48
  next
93
49
  end
94
50
 
51
+ pane = herdr_client.current_pane
95
52
  entry = {
96
- 'pane_id' => pane.id,
97
- 'window_id' => pane.window_id,
98
- 'session' => session,
53
+ 'workspace_id' => pane&.workspace_id || ENV['HERDR_WORKSPACE_ID'],
54
+ 'tab_id' => pane&.tab_id || ENV['HERDR_TAB_ID'],
55
+ 'pane_id' => pane&.id || ENV['HERDR_PANE_ID'],
56
+ 'agent' => pane&.agent,
99
57
  'timestamp' => Time.now.to_i,
100
- 'pane_cmd' => pane.command,
101
- 'message' => message,
102
- 'type' => @opts.type,
103
- }
58
+ 'message' => message,
59
+ 'type' => @opts.type,
60
+ }.compact
104
61
 
105
62
  data = read_log
106
- # One entry per pane — replace existing, newest first
107
- data.reject! { |e| e['pane_id'] == pane.id }
63
+ data.reject! { |existing| entry['pane_id'] && existing['pane_id'] == entry['pane_id'] }
108
64
  data.unshift(entry)
109
65
  write_log(data)
110
-
111
- binpath = `command -v terminal-notifier`
112
- system(binpath,
113
- '-title', @preset[:title],
114
- '-message', message,
115
- '-sound', @preset[:sound])
66
+ herdr_client.notify(@preset[:title], body: message, sound: @preset[:sound])
116
67
  end
117
68
 
118
69
  add_subcmd(:ls) do
119
70
  data = read_log
71
+ live_ids = live_panes.call.ids
72
+ current = data.select { |entry| !entry['pane_id'] || live_ids.include?(entry['pane_id']) }
73
+ removed = data.size - current.size
74
+ write_log(current) if removed.positive?
120
75
 
121
- if data.empty?
122
- puts "No notifications."
123
- else
124
- live_ids = Hiiro::Tmux::Panes.fetch(all: true).ids.to_set
125
- pruned = data.reject { |e| live_ids.include?(e['pane_id']) }
126
- data = data.select { |e| live_ids.include?(e['pane_id']) }
127
- write_log(data) if pruned.any?
128
-
129
- if data.empty?
130
- puts "No notifications (#{pruned.size} stale entries removed)."
131
- else
132
- puts "(#{pruned.size} stale entries removed.)" if pruned.any?
133
- data.each_with_index do |e, i|
134
- time_str = Time.at(e['timestamp']).strftime('%H:%M:%S')
135
- prefix = TYPE_PRESETS[e['type']]&.dig(:prefix) || '[INFO]'
136
- puts "%3d) %s [%s/%s] %s: %s (%s)" % [i, prefix, e['session'], e['pane_id'], e['pane_cmd'], e['message'], time_str]
137
- end
138
- end
76
+ if current.empty?
77
+ puts(removed.positive? ? "No notifications (#{removed} stale entries removed)." : 'No notifications.')
78
+ next
79
+ end
80
+
81
+ current.each_with_index do |entry, index|
82
+ time = Time.at(entry['timestamp']).strftime('%H:%M:%S')
83
+ prefix = TYPE_PRESETS[entry['type']]&.dig(:prefix) || '[INFO]'
84
+ location = [entry['workspace_id'], entry['tab_id'], entry['pane_id']].compact.join('/')
85
+ actor = entry['agent'] || 'pane'
86
+ puts "%3d) %s [%s] %s: %s (%s)" % [index, prefix, location, actor, entry['message'], time]
139
87
  end
140
88
  end
141
89
 
142
90
  add_subcmd(:prune) do
143
- data = read_log
144
- live_ids = Hiiro::Tmux::Panes.fetch(all: true).ids.to_set
145
- pruned = data.reject { |e| live_ids.include?(e['pane_id']) }
146
- write_log(data.select { |e| live_ids.include?(e['pane_id']) })
147
- puts "Removed #{pruned.size} stale notification(s). #{data.size - pruned.size} remaining."
91
+ data = read_log
92
+ live_ids = live_panes.call.ids
93
+ current = data.select { |entry| !entry['pane_id'] || live_ids.include?(entry['pane_id']) }
94
+ write_log(current)
95
+ puts "Removed #{data.size - current.size} stale notification(s). #{current.size} remaining."
148
96
  end
149
97
 
150
98
  add_subcmd(:menu) do
151
- data = read_log
152
- live_ids = Hiiro::Tmux::Panes.fetch(all: true).ids.to_set
153
- entries = data.select { |e| live_ids.include?(e['pane_id']) }
154
- write_log(entries) if entries.size != data.size
155
-
99
+ entries = read_log
156
100
  if entries.empty?
101
+ puts 'No notifications.'
157
102
  next
158
103
  end
159
104
 
160
- menu_args = ['-T', 'Notifications']
161
-
162
- entries.first(10).each_with_index do |e, idx|
163
- msg = e['message']
164
- msg = msg[0..46] + '...' if msg.length > 50
165
- prefix = TYPE_PRESETS[e['type']]&.dig(:prefix) || '[INFO]'
166
- label = "#{idx}: #{prefix} [#{e['pane_id']}] #{e['pane_cmd']}: #{msg}"
167
- menu_args.push(label, idx.to_s, "run-shell 'h notify jump #{idx}'")
105
+ mapping = entries.each_with_index.each_with_object({}) do |(entry, index), choices|
106
+ prefix = TYPE_PRESETS[entry['type']]&.dig(:prefix) || '[INFO]'
107
+ choices["#{prefix} #{entry['message']} [#{entry['pane_id'] || 'no pane'}]"] = index
168
108
  end
169
-
170
- menu_args.push('', '', 'Clear all', 'c', "run-shell 'h notify clear'")
171
- system('tmux', 'display-menu', *menu_args)
109
+ index = fuzzyfind_from_map(mapping)
110
+ run_subcmd(:jump, index.to_s) if index
172
111
  end
173
112
 
174
- add_subcmd(:jump) do |index_str = nil|
175
- if index_str.nil?
176
- puts "Usage: h notify jump INDEX"
113
+ add_subcmd(:jump) do |index_string = nil|
114
+ unless index_string
115
+ puts 'Usage: h notify jump INDEX'
177
116
  next
178
117
  end
179
118
 
180
- index = index_str.to_i
181
- data = read_log
182
-
183
- next if index >= data.length
184
-
119
+ data = read_log
120
+ index = index_string.to_i
185
121
  entry = data[index]
122
+ next unless entry
186
123
 
187
- unless pane_exists?(entry['pane_id'])
188
- data.delete_at(index)
189
- write_log(data)
190
- next
191
- end
192
-
193
- system('tmux', 'switch-client', '-t', entry['session'])
194
- system('tmux', 'select-window', '-t', entry['window_id'])
195
- system('tmux', 'select-pane', '-t', entry['pane_id'])
196
-
124
+ herdr_client.focus_workspace(entry['workspace_id']) if entry['workspace_id']
125
+ herdr_client.focus_tab(entry['tab_id']) if entry['tab_id']
197
126
  data.delete_at(index)
198
127
  write_log(data)
199
128
  end
200
129
 
201
- add_subcmd(:clear) do
202
- write_log([])
203
- end
130
+ add_subcmd(:clear) { write_log([]) }
204
131
 
205
- # Called by tmux hook: after-kill-pane
206
132
  add_subcmd(:remove_pane) do |pane_id = nil|
207
133
  next unless pane_id
208
- data = read_log
209
- data.reject! { |e| e['pane_id'] == pane_id }
210
- write_log(data)
134
+ write_log(read_log.reject { |entry| entry['pane_id'] == pane_id })
211
135
  end
212
136
 
213
- # Called by tmux hook: window-unlinked
214
- add_subcmd(:remove_window) do |window_id = nil|
215
- next unless window_id
216
- data = read_log
217
- data.reject! { |e| e['window_id'] == window_id }
218
- write_log(data)
137
+ add_subcmd(:remove_window, :remove_tab) do |tab_id = nil|
138
+ next unless tab_id
139
+ write_log(read_log.reject { |entry| entry['tab_id'] == tab_id })
219
140
  end
220
141
 
221
- # Called by tmux hook: session-closed
222
- add_subcmd(:remove_session) do |sess = nil|
223
- next unless sess
224
- data = read_log
225
- data.reject! { |e| e['session'] == sess }
226
- write_log(data)
142
+ add_subcmd(:remove_session, :remove_workspace) do |workspace_id = nil|
143
+ next unless workspace_id
144
+ write_log(read_log.reject { |entry| entry['workspace_id'] == workspace_id })
227
145
  end
228
146
 
229
- # --- h notify tmux ---
230
-
231
- add_subcmd(:tmux) do
232
- make_child {
233
- add_subcmd(:setup) do
234
- FileUtils.mkdir_p(File.dirname(NOTIFY_CONF))
235
- File.write(NOTIFY_CONF, NOTIFY_TMUX_CONF_CONTENT)
236
- puts "Wrote #{NOTIFY_CONF}"
237
- end
238
-
239
- add_subcmd(:add_hooks) do
240
- source_line = "source-file #{NOTIFY_CONF}"
241
-
242
- if File.exist?(TMUX_CONF)
243
- existing = File.read(TMUX_CONF)
244
- if existing.include?(source_line)
245
- puts "#{TMUX_CONF} already sources #{NOTIFY_CONF}"
246
- else
247
- File.open(TMUX_CONF, 'a') { |f| f.puts; f.puts source_line }
248
- puts "Added '#{source_line}' to #{TMUX_CONF}"
249
- end
250
- else
251
- File.write(TMUX_CONF, source_line + "\n")
252
- puts "Created #{TMUX_CONF} with source line"
253
- end
254
-
255
- system('tmux', 'source-file', TMUX_CONF)
256
- puts "Reloaded #{TMUX_CONF}"
257
- end
258
-
259
- add_subcmd(:reset_hooks) do
260
- NOTIFY_TMUX_HOOKS.each do |hook|
261
- system('tmux', 'set-hook', '-gu', hook)
262
- puts "Unset tmux hook: #{hook}"
263
- end
147
+ add_subcmd(:herdr) do
148
+ run_child(:herdr) do |child|
149
+ child.add_subcmd(:setup) do
150
+ puts 'No hooks are required. Hiiro uses Herdr notifications and live pane IDs directly.'
264
151
  end
265
-
266
- add_subcmd(:load_hooks) do
267
- system('tmux', 'source-file', TMUX_CONF)
268
- puts "Sourced #{TMUX_CONF}"
269
- end
270
- }.run
152
+ end
271
153
  end
272
154
 
273
- # --- h notify claude ---
274
-
275
155
  add_subcmd(:claude) do
276
156
  require 'json'
277
157
 
278
- read_claude_settings = lambda do
279
- return {} unless File.exist?(CLAUDE_SETTINGS)
280
- JSON.parse(File.read(CLAUDE_SETTINGS))
158
+ read_settings = lambda do
159
+ File.exist?(CLAUDE_SETTINGS) ? JSON.parse(File.read(CLAUDE_SETTINGS)) : {}
281
160
  rescue JSON::ParserError
282
161
  {}
283
162
  end
163
+ write_settings = ->(settings) { File.write(CLAUDE_SETTINGS, JSON.pretty_generate(settings)) }
284
164
 
285
- write_claude_settings = lambda do |settings|
286
- File.write(CLAUDE_SETTINGS, JSON.pretty_generate(settings))
287
- end
288
-
289
- make_child {
290
- add_subcmd(:setup) do
291
- settings = read_claude_settings.call
165
+ run_child(:claude) do |child|
166
+ child.add_subcmd(:setup, :add_hooks) do
167
+ settings = read_settings.call
292
168
  settings['hooks'] ||= {}
293
169
  settings['hooks']['Notification'] = [{ 'hooks' => [{ 'type' => 'command', 'command' => CLAUDE_NOTIFICATION_CMD }] }]
294
- settings['hooks']['Stop'] = [{ 'hooks' => [{ 'type' => 'command', 'command' => CLAUDE_STOP_CMD }] }]
295
- write_claude_settings.call(settings)
296
- puts "Updated #{CLAUDE_SETTINGS}"
297
- puts " Notification -> h alert + h notify push -t info"
298
- puts " Stop -> h alert + h notify push -t success"
299
- end
300
-
301
- add_subcmd(:add_hooks) do
302
- settings = read_claude_settings.call
303
- unless File.exist?(CLAUDE_SETTINGS)
304
- puts "#{CLAUDE_SETTINGS} not found"
305
- next
306
- end
307
-
308
- settings['hooks'] ||= {}
309
-
310
- # Inject h notify push into existing commands if not already present,
311
- # otherwise write fresh hooks
312
- %w[Notification Stop].each do |event|
313
- existing_hooks = settings.dig('hooks', event, 0, 'hooks') || []
314
- if existing_hooks.any? { |h| h['command']&.include?('h notify push') }
315
- puts "#{event} hook already includes h notify push — skipping"
316
- next
317
- end
318
-
319
- cmd = event == 'Notification' ? CLAUDE_NOTIFICATION_CMD : CLAUDE_STOP_CMD
320
- settings['hooks'][event] = [{ 'hooks' => [{ 'type' => 'command', 'command' => cmd }] }]
321
- puts "Set #{event} hook -> h alert + h notify push"
322
- end
323
-
324
- write_claude_settings.call(settings)
170
+ settings['hooks']['Stop'] = [{ 'hooks' => [{ 'type' => 'command', 'command' => CLAUDE_STOP_CMD }] }]
171
+ write_settings.call(settings)
172
+ puts "Updated #{CLAUDE_SETTINGS} with Herdr-backed Hiiro notifications"
325
173
  end
326
174
 
327
- add_subcmd(:reset_hooks) do
175
+ child.add_subcmd(:reset_hooks) do
328
176
  unless File.exist?(CLAUDE_SETTINGS)
329
177
  puts "#{CLAUDE_SETTINGS} not found"
330
178
  next
331
179
  end
332
180
 
333
- settings = read_claude_settings.call
334
- settings['hooks'] ||= {}
335
-
181
+ settings = read_settings.call
336
182
  %w[Notification Stop].each do |event|
337
183
  hooks = settings.dig('hooks', event, 0, 'hooks') || []
338
- hooks.each do |h|
339
- next unless h['command']&.include?('h notify push')
340
- # Strip the h notify push portion from the command
341
- h['command'] = h['command']
342
- .split(';')
343
- .reject { |part| part.strip.start_with?('h notify push') }
344
- .join(';')
345
- .strip
346
- end
347
- puts "Stripped h notify push from #{event} hook"
184
+ hooks.reject! { |hook| hook['command']&.include?('h notify push') }
348
185
  end
349
-
350
- write_claude_settings.call(settings)
186
+ write_settings.call(settings)
351
187
  end
352
188
 
353
- add_subcmd(:load_hooks) do
354
- puts "Claude Code settings are loaded automatically on startup."
355
- puts "Restart claude to pick up changes to #{CLAUDE_SETTINGS}"
189
+ child.add_subcmd(:load_hooks) do
190
+ puts 'Claude Code loads hook settings on startup; restart it to pick up changes.'
356
191
  end
357
- }.run
192
+ end
358
193
  end
359
194
  end