lux-hammer 0.3.22 → 0.3.23
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/.version +1 -1
- data/recipes/lib/llm/plan.rb +17 -8
- data/recipes/lib/llm/wrap.rb +497 -87
- data/recipes/llm.rb +293 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74dd44bca6cf689dcfe0396d90e05e51b0605821314fb6d5b742502662f4e8e8
|
|
4
|
+
data.tar.gz: a84eaa4fcce83f951905bab3419d18adfcce409cd0f9d2f23db910569443a2ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 044a31c525aba212012888f39d938fa37dc2e69e59097fa10f1dda53723b0a267dae2dad0e77887c19ad801a64fcb47e03b8aa95d916b04474d5239628a9b8f0
|
|
7
|
+
data.tar.gz: 3ec9940429c06e4bfb00669184fe3d4f78fff0ed693f857fcd37c682bf89b70a214f3a7e92e24826a1ab5889c62de26f05e0f9b78fe22c1c65b12227d4a18681
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.23
|
data/recipes/lib/llm/plan.rb
CHANGED
|
@@ -455,11 +455,10 @@ module LlmPlan
|
|
|
455
455
|
end
|
|
456
456
|
end
|
|
457
457
|
|
|
458
|
-
# Columns at which the clause and the counts start, so rows line up.
|
|
459
|
-
#
|
|
458
|
+
# Columns at which the clause and the counts start, so rows line up. Taken
|
|
459
|
+
# from every planned path, so problem rows share the column too.
|
|
460
460
|
def width
|
|
461
|
-
@width ||= (
|
|
462
|
-
.map(&:length).max || 0) + 4
|
|
461
|
+
@width ||= (@outcome.bundle.entries.map { |entry| entry.path.length }.max || 0) + 4
|
|
463
462
|
end
|
|
464
463
|
|
|
465
464
|
def note_width
|
|
@@ -560,18 +559,28 @@ module LlmPlan
|
|
|
560
559
|
|
|
561
560
|
private
|
|
562
561
|
|
|
563
|
-
# What a run did, in the past tense.
|
|
562
|
+
# What a run did, in the past tense. Sorted by path, same as the manifest -
|
|
563
|
+
# a landed list is read the same way a planned one is.
|
|
564
564
|
def result
|
|
565
565
|
rows = []
|
|
566
|
-
@outcome.applied.
|
|
567
|
-
|
|
568
|
-
|
|
566
|
+
landed = @outcome.applied.map { |applied| [applied.entry, applied.note] } +
|
|
567
|
+
@outcome.skipped.map { |entry| [entry, 'already applied'] }
|
|
568
|
+
|
|
569
|
+
unless landed.empty?
|
|
570
|
+
rows << [paint('Landed', :green), nil]
|
|
571
|
+
landed.sort_by { |entry, _| entry.path }.each { |entry, note| rows << [landed_row(entry, note), nil] }
|
|
572
|
+
rows << ['', nil]
|
|
573
|
+
end
|
|
569
574
|
|
|
570
575
|
@outcome.drifted.each { |drifted| rows.concat drift_block(drifted) }
|
|
571
576
|
rows.concat summary
|
|
572
577
|
rows
|
|
573
578
|
end
|
|
574
579
|
|
|
580
|
+
def landed_row(entry, note)
|
|
581
|
+
" #{paint(entry.path.ljust(@manifest.width), :cyan)}#{paint(note, :gray)}"
|
|
582
|
+
end
|
|
583
|
+
|
|
575
584
|
# What a run would do, for a human to approve. One line per file, grouped
|
|
576
585
|
# and sorted, each with its own clause. Never counts of lines touched -
|
|
577
586
|
# that says nothing about whether the change is the right one.
|
data/recipes/lib/llm/wrap.rb
CHANGED
|
@@ -17,6 +17,12 @@ require 'shellwords'
|
|
|
17
17
|
# so text the program inserts for you (history recall, autocomplete, menu
|
|
18
18
|
# picks) never shows up - that is the price of working with any program at all.
|
|
19
19
|
#
|
|
20
|
+
# A line starting with ! is taken over: it never reaches the child at all, it
|
|
21
|
+
# runs in a shell here, and its output is shown in the bar (Bang, Pane). The
|
|
22
|
+
# round trip that saves is the point - `!git status` typed at an agent is a
|
|
23
|
+
# tool call, a permission check and a model turn for something the shell
|
|
24
|
+
# answers in milliseconds, and this way it costs no context either.
|
|
25
|
+
#
|
|
20
26
|
# Wrapping a program hides it: the child lives on our PTY, in its own session,
|
|
21
27
|
# so anything outside looking at the terminal sees this process and not the
|
|
22
28
|
# program. Terminals that watch what is running in a pane - Herdr naming a tab,
|
|
@@ -45,6 +51,10 @@ module LlmWrap
|
|
|
45
51
|
DRAIN_MAX ||= 262_144
|
|
46
52
|
MAX_BLOCK ||= 1.5
|
|
47
53
|
|
|
54
|
+
# The bar grows to show ! output, and this is what it may never take: the
|
|
55
|
+
# child keeps at least this many rows whatever the output was.
|
|
56
|
+
MIN_CHILD ||= 5
|
|
57
|
+
|
|
48
58
|
# Introducers of the escape sequences that carry a string payload:
|
|
49
59
|
# OSC ], DCS P, SOS X, PM ^, APC _. They run to BEL or ST rather than to a
|
|
50
60
|
# final byte, so both stream parsers here - KeyBuffer on the way in, OutScan
|
|
@@ -100,6 +110,87 @@ module LlmWrap
|
|
|
100
110
|
end
|
|
101
111
|
end
|
|
102
112
|
|
|
113
|
+
# Drawing shared by everything that puts a row in the bar: the prompt history
|
|
114
|
+
# and the ! output pane.
|
|
115
|
+
module Text
|
|
116
|
+
CYAN = "\e[36m"
|
|
117
|
+
YELLOW = "\e[33m"
|
|
118
|
+
DIM = "\e[2m"
|
|
119
|
+
GRAY = "\e[38;5;245m"
|
|
120
|
+
REV = "\e[7m"
|
|
121
|
+
RESET = "\e[0m"
|
|
122
|
+
|
|
123
|
+
# Dashed, and deliberately not the solid U+2500 "─" it wants to be.
|
|
124
|
+
#
|
|
125
|
+
# A pane watcher works out where a program's own furniture is by looking for
|
|
126
|
+
# the last solid rule on the screen: Herdr hangs its "the prompt box is
|
|
127
|
+
# here" and "the permission dialog is here" regions off it. Our bar is below
|
|
128
|
+
# everything, so a solid rule down here becomes the last one and those
|
|
129
|
+
# regions land on the prompt history instead - detection then sees no prompt
|
|
130
|
+
# and no dialog, and the pane reads as neither idle nor blocked. Any glyph
|
|
131
|
+
# that is not box-drawing keeps the bar out of that reckoning; a dashed rule
|
|
132
|
+
# is also a fair signal that this row is not part of the program above.
|
|
133
|
+
RULE ||= '┄'
|
|
134
|
+
|
|
135
|
+
# Roughly the double-width ranges of UEAW - enough to keep CJK and emoji
|
|
136
|
+
# from overflowing the bar without pulling in a character-width gem.
|
|
137
|
+
WIDE ||= [
|
|
138
|
+
0x1100..0x115f, 0x2e80..0x303e, 0x3041..0x33ff, 0x3400..0x4dbf,
|
|
139
|
+
0x4e00..0x9fff, 0xa000..0xa4cf, 0xac00..0xd7a3, 0xf900..0xfaff,
|
|
140
|
+
0xfe30..0xfe6f, 0xff00..0xff60, 0xffe0..0xffe6,
|
|
141
|
+
0x1f300..0x1f64f, 0x1f900..0x1f9ff, 0x20000..0x2fffd
|
|
142
|
+
].freeze
|
|
143
|
+
ZERO ||= [0xfe00..0xfe0f, 0x200b..0x200f].freeze
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# A full-width rule with the label centred in it, marking off the bar from
|
|
148
|
+
# whatever the wrapped program is drawing above it.
|
|
149
|
+
def rule(cols, label)
|
|
150
|
+
return '' if cols <= 0
|
|
151
|
+
|
|
152
|
+
label = " #{label} "
|
|
153
|
+
pad = cols - width(label)
|
|
154
|
+
return "#{GRAY}#{RULE * cols}#{RESET}" if pad < 2
|
|
155
|
+
|
|
156
|
+
left = pad / 2
|
|
157
|
+
"#{GRAY}#{RULE * left}#{label}#{RULE * (pad - left)}#{RESET}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def dim(text)
|
|
161
|
+
"#{DIM}#{text}#{RESET}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def clip(text, cells)
|
|
165
|
+
return '' if cells <= 0
|
|
166
|
+
return text if width(text) <= cells
|
|
167
|
+
|
|
168
|
+
out = +''
|
|
169
|
+
used = 0
|
|
170
|
+
text.each_char do |ch|
|
|
171
|
+
w = char_width(ch)
|
|
172
|
+
break if used + w > cells - 1
|
|
173
|
+
|
|
174
|
+
out << ch
|
|
175
|
+
used += w
|
|
176
|
+
end
|
|
177
|
+
out << '…'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def width(text)
|
|
181
|
+
text.each_char.sum { |ch| char_width(ch) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def char_width(char)
|
|
185
|
+
cp = char.ord
|
|
186
|
+
return 0 if cp == 0x200d || ZERO.any? { |r| r.cover?(cp) }
|
|
187
|
+
|
|
188
|
+
WIDE.any? { |r| r.cover?(cp) } ? 2 : 1
|
|
189
|
+
rescue RangeError
|
|
190
|
+
1
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
103
194
|
# Taking the child's name costs us our own command line, and that is the only
|
|
104
195
|
# record of how the pane was started - Herdr's clone-tab reads it back off the
|
|
105
196
|
# foreground process to reopen the same wrapper. So leave it in a file named
|
|
@@ -218,6 +309,7 @@ module LlmWrap
|
|
|
218
309
|
@origin = origin
|
|
219
310
|
@bar_rows = keep + 1 # the prompts, plus the rule above them
|
|
220
311
|
@keys = KeyBuffer.new(keep)
|
|
312
|
+
@pane = nil # the last ! command's output, while one is up
|
|
221
313
|
@out = OutScan.new
|
|
222
314
|
@winch = false
|
|
223
315
|
@dirty = true
|
|
@@ -324,8 +416,11 @@ module LlmWrap
|
|
|
324
416
|
# child holding a saved cursor across this point would lose it - drawing
|
|
325
417
|
# only once output has settled makes that vanishingly rare in practice.
|
|
326
418
|
def draw_bar
|
|
419
|
+
lines = bar_lines
|
|
420
|
+
resize_bar(lines.size)
|
|
421
|
+
|
|
327
422
|
out = +"\e7\e[r"
|
|
328
|
-
|
|
423
|
+
lines.first(@bar_rows).each_with_index do |line, i|
|
|
329
424
|
out << "\e[#{child_rows + 1 + i};1H\e[K" << line
|
|
330
425
|
end
|
|
331
426
|
out << "\e[1;#{child_rows}r\e8"
|
|
@@ -335,6 +430,37 @@ module LlmWrap
|
|
|
335
430
|
@drawn_at = now
|
|
336
431
|
end
|
|
337
432
|
|
|
433
|
+
# The prompt history, or the last ! command's output while one is up. Both
|
|
434
|
+
# are painted the same way; only the number of rows differs.
|
|
435
|
+
def bar_lines
|
|
436
|
+
@pane ? @pane.lines(@cols, max_bar) : @keys.lines(@cols)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def max_bar
|
|
440
|
+
[@rows - MIN_CHILD, 2].max
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# A taller bar moves the line the child ends on, so the scroll region and
|
|
444
|
+
# the child's own idea of the screen have to move with it. Rows handed back
|
|
445
|
+
# still have our text on them and the child has no reason to touch them
|
|
446
|
+
# until it repaints, so clear them on the way out.
|
|
447
|
+
def resize_bar(rows)
|
|
448
|
+
rows = rows.clamp(1, max_bar)
|
|
449
|
+
return if rows == @bar_rows
|
|
450
|
+
|
|
451
|
+
back = @bar_rows - rows # positive when the bar shrinks
|
|
452
|
+
@bar_rows = rows
|
|
453
|
+
|
|
454
|
+
if back.positive?
|
|
455
|
+
out = +"\e7\e[r"
|
|
456
|
+
(child_rows - back + 1..child_rows).each { |row| out << "\e[#{row};1H\e[K" }
|
|
457
|
+
emit out << "\e8"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
set_region
|
|
461
|
+
resize_child # the kernel SIGWINCHes the child, which repaints itself
|
|
462
|
+
end
|
|
463
|
+
|
|
338
464
|
def emit(str)
|
|
339
465
|
$stdout.write(str)
|
|
340
466
|
end
|
|
@@ -366,10 +492,27 @@ module LlmWrap
|
|
|
366
492
|
ready[0].each do |io|
|
|
367
493
|
if io.equal?($stdin)
|
|
368
494
|
data = slurp($stdin) or return
|
|
369
|
-
@pty_in.write(data)
|
|
370
495
|
taking = capture?
|
|
371
|
-
|
|
496
|
+
|
|
497
|
+
# KeyBuffer decides what the child is allowed to see: everything,
|
|
498
|
+
# unless a ! line is being typed, which is ours from the ! to the
|
|
499
|
+
# Enter. Nothing is captured at all while the child is asking for a
|
|
500
|
+
# password, and then it gets the bytes untouched.
|
|
501
|
+
if taking
|
|
502
|
+
@dirty = true if @keys.feed(data)
|
|
503
|
+
@pty_in.write(@keys.pass) unless @keys.pass.empty?
|
|
504
|
+
else
|
|
505
|
+
@pty_in.write(data)
|
|
506
|
+
end
|
|
507
|
+
|
|
372
508
|
trace_in(data, taking)
|
|
509
|
+
|
|
510
|
+
# After the trace, so the log shows the line that ran before its
|
|
511
|
+
# output arrives, and after the write, so nothing waits on a shell.
|
|
512
|
+
if taking
|
|
513
|
+
@keys.bangs.each { |cmd| run_bang(cmd) }
|
|
514
|
+
close_pane if @keys.typing?
|
|
515
|
+
end
|
|
373
516
|
else
|
|
374
517
|
data = drain(@pty_out) or return
|
|
375
518
|
$stdout.write(data)
|
|
@@ -389,6 +532,35 @@ module LlmWrap
|
|
|
389
532
|
nil
|
|
390
533
|
end
|
|
391
534
|
|
|
535
|
+
# A ! line never went to the child, so this is the whole of it: run it here
|
|
536
|
+
# and put the result in the bar. A bare ! puts the bar back to the prompt
|
|
537
|
+
# history, which is how you close the output without running anything.
|
|
538
|
+
#
|
|
539
|
+
# The pump is stopped while the shell runs, which is why Bang keeps a
|
|
540
|
+
# deadline on it. The command is painted before it runs so a slow one does
|
|
541
|
+
# not look like a hung terminal.
|
|
542
|
+
def run_bang(cmd)
|
|
543
|
+
return close_pane if cmd.empty?
|
|
544
|
+
|
|
545
|
+
@pane = Bang.pending(cmd)
|
|
546
|
+
@dirty = true
|
|
547
|
+
paint
|
|
548
|
+
|
|
549
|
+
@pane = Bang.run(cmd)
|
|
550
|
+
trace('BANG', "#{cmd.inspect} -> exit=#{@pane.status.inspect} #{@pane.note}")
|
|
551
|
+
@dirty = true
|
|
552
|
+
paint
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# The first keystroke of the next line takes the screen back: the output was
|
|
556
|
+
# for the question you were about to ask, and now you are asking it.
|
|
557
|
+
def close_pane
|
|
558
|
+
return unless @pane
|
|
559
|
+
|
|
560
|
+
@pane = nil
|
|
561
|
+
@dirty = true
|
|
562
|
+
end
|
|
563
|
+
|
|
392
564
|
def slurp(io)
|
|
393
565
|
io.readpartial(CHUNK)
|
|
394
566
|
rescue EOFError, Errno::EIO, IOError
|
|
@@ -623,6 +795,195 @@ module LlmWrap
|
|
|
623
795
|
end
|
|
624
796
|
end
|
|
625
797
|
|
|
798
|
+
# What a ! command left behind, as rows for the bar.
|
|
799
|
+
#
|
|
800
|
+
# It stays on this side of the wrapper. Nothing is typed into the child, so
|
|
801
|
+
# the output is not in the transcript and not in the model's context - and it
|
|
802
|
+
# is not painted over either, because these rows are outside the child's
|
|
803
|
+
# screen by construction, the same trick that keeps the prompt history safe.
|
|
804
|
+
class Pane
|
|
805
|
+
include Text
|
|
806
|
+
|
|
807
|
+
LABEL ||= 'shell'
|
|
808
|
+
|
|
809
|
+
attr_reader :cmd, :pwd, :out, :status, :note
|
|
810
|
+
|
|
811
|
+
def initialize(cmd, out: [], status: nil, note: nil)
|
|
812
|
+
@cmd = cmd
|
|
813
|
+
@pwd = Dir.pwd
|
|
814
|
+
@out = out
|
|
815
|
+
@status = status
|
|
816
|
+
@note = note
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
# Where it ran, what ran, then what it printed - under a rule saying whose
|
|
820
|
+
# rows these are. `max` is everything the bar is allowed to take.
|
|
821
|
+
#
|
|
822
|
+
# The command keeps the ! you typed rather than wearing a shell's $: it is
|
|
823
|
+
# the same row you were just looking at while typing it, and the bar is a
|
|
824
|
+
# record of what you typed.
|
|
825
|
+
def lines(cols, max)
|
|
826
|
+
head = [dim(clip(tilde(@pwd), cols)),
|
|
827
|
+
"#{YELLOW}!#{RESET} #{clip(@cmd, cols - 2)}"]
|
|
828
|
+
body = @out.empty? ? [dim('(no output)')] : @out.map { |line| clip(line, cols) }
|
|
829
|
+
|
|
830
|
+
[rule(cols, label)] + head + fit(body, max - 1 - head.size)
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
private
|
|
834
|
+
|
|
835
|
+
def label
|
|
836
|
+
bits = [LABEL]
|
|
837
|
+
bits << "exit #{@status}" if @status.to_i.positive?
|
|
838
|
+
bits << @note if @note
|
|
839
|
+
bits.join(' - ')
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Long output is cut from the bottom. The bar is a few rows and not a pager:
|
|
843
|
+
# what is worth reading here a command says first, and anything else is a
|
|
844
|
+
# pipe into head or a file away.
|
|
845
|
+
def fit(rows, room)
|
|
846
|
+
return rows if rows.size <= room
|
|
847
|
+
return [] if room < 1
|
|
848
|
+
|
|
849
|
+
rows.first(room - 1) << dim("… +#{rows.size - room + 1} more lines")
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def tilde(path)
|
|
853
|
+
home = Dir.home
|
|
854
|
+
path.start_with?("#{home}/") || path == home ? path.sub(home, '~') : path
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# Runs a ! line, here, instead of handing it to the agent.
|
|
859
|
+
module Bang
|
|
860
|
+
# A ! line is meant to be quick, and the pump is stopped while it runs: no
|
|
861
|
+
# keys reach the child and the screen does not repaint. Anything slower than
|
|
862
|
+
# this wants to be a real terminal, or the agent's own tools.
|
|
863
|
+
TIMEOUT ||= 10
|
|
864
|
+
MAX_OUT ||= 65_536
|
|
865
|
+
READ ||= 4096
|
|
866
|
+
|
|
867
|
+
# `cd` on its own, with nothing that would need a shell to work out. Every !
|
|
868
|
+
# line gets a fresh shell, so a cd inside one dies with it and the pwd row
|
|
869
|
+
# above the output would be a lie the moment you tried. Do it here instead.
|
|
870
|
+
# This moves us and nothing else: the agent has its own working directory
|
|
871
|
+
# and we are not reaching into it.
|
|
872
|
+
CD ||= /\Acd(?:\s+(?<path>[^|&;<>()`$\n]*\S))?\z/
|
|
873
|
+
|
|
874
|
+
# Yours, non-interactive - so zsh syntax works, but ~/.zshrc is not read and
|
|
875
|
+
# your aliases and functions are not there. Loading it per line would cost
|
|
876
|
+
# more than the round trip this is here to save.
|
|
877
|
+
def self.shell
|
|
878
|
+
sh = ENV['SHELL'].to_s
|
|
879
|
+
sh.empty? ? '/bin/sh' : sh
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def self.pending(cmd)
|
|
883
|
+
Pane.new(cmd, note: 'running')
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def self.run(cmd)
|
|
887
|
+
cmd = cmd.strip
|
|
888
|
+
(m = CD.match(cmd)) ? chdir(cmd, m[:path]) : capture(cmd)
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def self.chdir(cmd, path)
|
|
892
|
+
prev = Dir.pwd
|
|
893
|
+
Dir.chdir(target(path))
|
|
894
|
+
@back = prev
|
|
895
|
+
Pane.new(cmd, status: 0)
|
|
896
|
+
rescue SystemCallError => e
|
|
897
|
+
Pane.new(cmd, out: [e.message], status: 1)
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def self.target(path)
|
|
901
|
+
case path
|
|
902
|
+
when nil then Dir.home
|
|
903
|
+
when '-' then @back || Dir.pwd
|
|
904
|
+
else File.expand_path(path)
|
|
905
|
+
end
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def self.capture(cmd)
|
|
909
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
910
|
+
note = nil
|
|
911
|
+
# No keyboard: our stdin is the terminal, in raw mode, and a command that
|
|
912
|
+
# reads it would be taking the keystrokes meant for the agent. Anything
|
|
913
|
+
# wanting input is not a ! line.
|
|
914
|
+
io = IO.popen([shell, '-c', cmd], in: File::NULL, err: %i[child out], pgroup: true)
|
|
915
|
+
till = clock + TIMEOUT
|
|
916
|
+
|
|
917
|
+
loop do
|
|
918
|
+
# Waiting on the pipe and not on the process: a command that forks and
|
|
919
|
+
# leaves the pipe open (`something &`) holds this until the deadline,
|
|
920
|
+
# and then it is stopped like anything else that overstayed.
|
|
921
|
+
left = till - clock
|
|
922
|
+
if left <= 0 || !IO.select([io], nil, nil, left)
|
|
923
|
+
note = "timed out after #{TIMEOUT}s"
|
|
924
|
+
break
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
begin
|
|
928
|
+
out << io.readpartial(READ)
|
|
929
|
+
rescue EOFError
|
|
930
|
+
break
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
note = 'output cut' if out.bytesize >= MAX_OUT
|
|
934
|
+
break if note
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
stop(io) if note
|
|
938
|
+
Pane.new(cmd, out: rows(out), status: close(io), note: note)
|
|
939
|
+
rescue SystemCallError, IOError => e
|
|
940
|
+
Pane.new(cmd, out: [e.message], status: 1, note: 'did not run')
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# Whatever it is doing, it does not get to outlive the line that started it -
|
|
944
|
+
# the wrapper is not pumping keys while it lives. TERM first, then insist,
|
|
945
|
+
# and the whole group because a shell -c is usually not the only process.
|
|
946
|
+
def self.stop(io)
|
|
947
|
+
Process.kill('TERM', -io.pid)
|
|
948
|
+
sleep 0.05
|
|
949
|
+
Process.kill('KILL', -io.pid)
|
|
950
|
+
rescue SystemCallError
|
|
951
|
+
nil
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def self.close(io)
|
|
955
|
+
io.close
|
|
956
|
+
$?&.exitstatus
|
|
957
|
+
rescue SystemCallError, IOError
|
|
958
|
+
nil
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
def self.clock
|
|
962
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
# Output as bar rows: no escape sequences left to fight with our own colour
|
|
966
|
+
# or walk the cursor out of the pane, and tabs where a terminal would put
|
|
967
|
+
# them. The trailing newline every command ends with is not a blank row.
|
|
968
|
+
def self.rows(bytes)
|
|
969
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8).scrub('')
|
|
970
|
+
out = text.gsub(/\e\[[\d;?]*[ -\/]*[@-~]|\e[\]P^_X].*?(?:\a|\e\\)|\e./m, '')
|
|
971
|
+
.split("\n", -1)
|
|
972
|
+
.map { |line| untab(line).gsub(/[\x00-\x1f\x7f]/, '') }
|
|
973
|
+
|
|
974
|
+
out.pop while !out.empty? && out.last.empty?
|
|
975
|
+
out
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def self.untab(line)
|
|
979
|
+
return line unless line.include?("\t")
|
|
980
|
+
|
|
981
|
+
line.each_char.with_object(+'') do |ch, out|
|
|
982
|
+
ch == "\t" ? out << ' ' * (8 - (out.length % 8)) : out << ch
|
|
983
|
+
end
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
|
|
626
987
|
# Rebuilds the line you are typing from the raw byte stream on its way to the
|
|
627
988
|
# child, and keeps the last `keep` submitted lines.
|
|
628
989
|
#
|
|
@@ -630,10 +991,13 @@ module LlmWrap
|
|
|
630
991
|
# reads can split a UTF-8 character or an escape sequence, so bytes are
|
|
631
992
|
# accumulated in a binary buffer and only decoded when rendered.
|
|
632
993
|
class KeyBuffer
|
|
994
|
+
include Text
|
|
995
|
+
|
|
633
996
|
MAX_LEN ||= 4096
|
|
634
997
|
NO_PROMPTS ||= '(nothing typed yet)'
|
|
635
998
|
LABEL ||= 'prompt history'
|
|
636
999
|
|
|
1000
|
+
BANG = 0x21
|
|
637
1001
|
CR = 0x0d
|
|
638
1002
|
LF = 0x0a
|
|
639
1003
|
ESC = 0x1b
|
|
@@ -658,33 +1022,52 @@ module LlmWrap
|
|
|
658
1022
|
# CSI 27 ; <mods> ; <code> ~ (xterm modifyOtherKeys)
|
|
659
1023
|
XTERM_KEY ||= /\A\e\[27;(\d+);(\d+)~\z/
|
|
660
1024
|
|
|
661
|
-
# Roughly the double-width ranges of UEAW - enough to keep CJK and emoji
|
|
662
|
-
# from overflowing the bar without pulling in a character-width gem.
|
|
663
|
-
WIDE ||= [
|
|
664
|
-
0x1100..0x115f, 0x2e80..0x303e, 0x3041..0x33ff, 0x3400..0x4dbf,
|
|
665
|
-
0x4e00..0x9fff, 0xa000..0xa4cf, 0xac00..0xd7a3, 0xf900..0xfaff,
|
|
666
|
-
0xfe30..0xfe6f, 0xff00..0xff60, 0xffe0..0xffe6,
|
|
667
|
-
0x1f300..0x1f64f, 0x1f900..0x1f9ff, 0x20000..0x2fffd
|
|
668
|
-
].freeze
|
|
669
|
-
ZERO ||= [0xfe00..0xfe0f, 0x200b..0x200f].freeze
|
|
670
|
-
|
|
671
1025
|
def initialize(keep)
|
|
672
1026
|
@keep = keep
|
|
673
1027
|
@prompts = []
|
|
674
1028
|
@buf = binary
|
|
675
1029
|
@esc = nil
|
|
676
1030
|
@paste = false
|
|
1031
|
+
@bang = false
|
|
1032
|
+
@bangs = []
|
|
1033
|
+
@pass = binary
|
|
1034
|
+
@hold = binary
|
|
677
1035
|
end
|
|
678
1036
|
|
|
1037
|
+
# What the last feed decided, for the pump:
|
|
1038
|
+
#
|
|
1039
|
+
# pass the bytes the child is allowed to see. Everything, until a line
|
|
1040
|
+
# starts with !, and then nothing until that line is done with.
|
|
1041
|
+
# bangs ! lines submitted in it, in order. An empty one is a bare !.
|
|
1042
|
+
# typing? there is a line on the go, so the screen is wanted for it.
|
|
1043
|
+
attr_reader :pass, :bangs
|
|
1044
|
+
|
|
1045
|
+
def typing? = !@buf.empty?
|
|
1046
|
+
def bang? = @bang
|
|
1047
|
+
|
|
679
1048
|
# The line being typed right now, for the debug trace.
|
|
680
1049
|
def pending
|
|
681
1050
|
decode(@buf)
|
|
682
1051
|
end
|
|
683
1052
|
|
|
684
|
-
# Returns true when the
|
|
1053
|
+
# Returns true when what the bar shows changed.
|
|
685
1054
|
def feed(bytes)
|
|
686
|
-
|
|
687
|
-
|
|
1055
|
+
@pass = binary
|
|
1056
|
+
@hold = binary
|
|
1057
|
+
@bangs = []
|
|
1058
|
+
changed = false
|
|
1059
|
+
|
|
1060
|
+
bytes.each_byte do |b|
|
|
1061
|
+
@hold << b
|
|
1062
|
+
was = @bang
|
|
1063
|
+
hit = @esc ? escape(b) : plain(b)
|
|
1064
|
+
# A ! line redraws on every keystroke: the bar is the only place it is
|
|
1065
|
+
# shown, so it has to keep up the way an input box would.
|
|
1066
|
+
changed = true if hit || @bang || was
|
|
1067
|
+
release unless @esc
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
release # a sequence still arriving: the child waits no longer than today
|
|
688
1071
|
changed
|
|
689
1072
|
end
|
|
690
1073
|
|
|
@@ -704,39 +1087,29 @@ module LlmWrap
|
|
|
704
1087
|
end
|
|
705
1088
|
end
|
|
706
1089
|
|
|
707
|
-
[
|
|
1090
|
+
rows[-1] = typing(cols) if @bang
|
|
1091
|
+
[rule(cols, LABEL)] + rows
|
|
708
1092
|
end
|
|
709
1093
|
|
|
710
1094
|
private
|
|
711
1095
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
# the last solid rule on the screen: Herdr hangs its "the prompt box is
|
|
721
|
-
# here" and "the permission dialog is here" regions off it. Our bar is below
|
|
722
|
-
# everything, so a solid rule down here becomes the last one and those
|
|
723
|
-
# regions land on the prompt history instead - detection then sees no prompt
|
|
724
|
-
# and no dialog, and the pane reads as neither idle nor blocked. Any glyph
|
|
725
|
-
# that is not box-drawing keeps the bar out of that reckoning; a dashed rule
|
|
726
|
-
# is also a fair signal that this row is not part of the program above.
|
|
727
|
-
RULE ||= '┄'
|
|
728
|
-
|
|
729
|
-
# A full-width rule with the label centred in it, marking off the bar from
|
|
730
|
-
# whatever the wrapped program is drawing above it.
|
|
731
|
-
def rule(cols)
|
|
732
|
-
return '' if cols <= 0
|
|
1096
|
+
# A ! line is not going anywhere near the child, so nothing else on screen
|
|
1097
|
+
# is showing it: this row is the only place you can see what you are typing,
|
|
1098
|
+
# cursor included. It takes the newest prompt's row, which comes back the
|
|
1099
|
+
# moment the line is run or dropped.
|
|
1100
|
+
def typing(cols)
|
|
1101
|
+
text = decode(@buf).delete_prefix('!').lstrip
|
|
1102
|
+
"#{YELLOW}!#{RESET} #{clip(text, cols - 4)}#{REV} #{RESET}"
|
|
1103
|
+
end
|
|
733
1104
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1105
|
+
# Bytes reach the child unless a ! line is holding them back. @swallow
|
|
1106
|
+
# carries the Enter that ended one, which is ours and not the child's.
|
|
1107
|
+
def release
|
|
1108
|
+
return if @hold.empty?
|
|
737
1109
|
|
|
738
|
-
|
|
739
|
-
|
|
1110
|
+
@pass << @hold unless @bang || @swallow
|
|
1111
|
+
@swallow = false
|
|
1112
|
+
@hold = binary
|
|
740
1113
|
end
|
|
741
1114
|
|
|
742
1115
|
def binary
|
|
@@ -748,7 +1121,7 @@ module LlmWrap
|
|
|
748
1121
|
when ESC then @esc = binary << byte; false
|
|
749
1122
|
when CR, LF then @paste ? (append("\n"); false) : submit
|
|
750
1123
|
when DEL, BS then chop_char; false
|
|
751
|
-
when CTRL_U, CTRL_C then
|
|
1124
|
+
when CTRL_U, CTRL_C then clear_line; false
|
|
752
1125
|
when CTRL_W then drop_word; false
|
|
753
1126
|
when TAB then false # completion text is inserted by the app
|
|
754
1127
|
else
|
|
@@ -762,16 +1135,6 @@ module LlmWrap
|
|
|
762
1135
|
# bracketed-paste markers.
|
|
763
1136
|
def escape(byte)
|
|
764
1137
|
@esc << byte
|
|
765
|
-
|
|
766
|
-
if @esc.bytesize == 2
|
|
767
|
-
case byte
|
|
768
|
-
when 0x5b, 0x4f then return false # CSI / SS3: keep collecting
|
|
769
|
-
when *STRING_INTRO then return false # OSC / DCS / APC / PM / SOS
|
|
770
|
-
when CR, LF then @esc = nil; append("\n"); return false # Option+Enter
|
|
771
|
-
else @esc = nil; return false
|
|
772
|
-
end
|
|
773
|
-
end
|
|
774
|
-
|
|
775
1138
|
intro = @esc.getbyte(1)
|
|
776
1139
|
|
|
777
1140
|
# String sequences run to BEL or ST (ESC \) rather than a final byte, and
|
|
@@ -779,12 +1142,34 @@ module LlmWrap
|
|
|
779
1142
|
# question the program asked - Codex queries the fg/bg colour on startup
|
|
780
1143
|
# and gets back "\e]10;rgb:cdcd/d6d6/f4f4\e\\" - so the whole thing is
|
|
781
1144
|
# dropped. Treating them as CSI would spill that payload into the prompt.
|
|
782
|
-
|
|
1145
|
+
# An ESC in here is the start of that ST, so this goes before the resync.
|
|
1146
|
+
if @esc.bytesize > 2 && STRING_INTRO.include?(intro)
|
|
783
1147
|
@esc = nil if byte == BEL || (byte == 0x5c && @esc.getbyte(-2) == ESC)
|
|
784
1148
|
@esc = nil if @esc && @esc.bytesize > 1024 # unterminated: cut losses
|
|
785
1149
|
return false
|
|
786
1150
|
end
|
|
787
1151
|
|
|
1152
|
+
# An ESC part-way through a sequence never belongs to it: the one being
|
|
1153
|
+
# collected was abandoned and a new one starts here. That is the ordinary
|
|
1154
|
+
# shape of a bare Escape keypress followed by any other key - the terminal
|
|
1155
|
+
# sends "\e" and then the next key's own sequence - and of a read that
|
|
1156
|
+
# split one sequence off the end of another. Resync rather than give up:
|
|
1157
|
+
# dropping the state would leave the rest of the new sequence to arrive as
|
|
1158
|
+
# plain bytes, and "\e[27;1:3u" would land in the prompt as "[27;1:3u".
|
|
1159
|
+
if byte == ESC
|
|
1160
|
+
@esc = binary << byte
|
|
1161
|
+
return false
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
if @esc.bytesize == 2
|
|
1165
|
+
case byte
|
|
1166
|
+
when 0x5b, 0x4f then return false # CSI / SS3: keep collecting
|
|
1167
|
+
when *STRING_INTRO then return false # OSC / DCS / APC / PM / SOS
|
|
1168
|
+
when CR, LF then @esc = nil; append("\n"); return false # Option+Enter
|
|
1169
|
+
else @esc = nil; return false
|
|
1170
|
+
end
|
|
1171
|
+
end
|
|
1172
|
+
|
|
788
1173
|
if intro == 0x4f # SS3 is always three bytes
|
|
789
1174
|
@esc = nil
|
|
790
1175
|
return false
|
|
@@ -796,7 +1181,9 @@ module LlmWrap
|
|
|
796
1181
|
return csi(seq)
|
|
797
1182
|
end
|
|
798
1183
|
|
|
799
|
-
|
|
1184
|
+
# Lost the thread of it. Keep swallowing to the final byte rather than
|
|
1185
|
+
# dropping the state, for the same reason as the resync above.
|
|
1186
|
+
@esc = @esc.byteslice(0, 2) if @esc.bytesize > 32
|
|
800
1187
|
false
|
|
801
1188
|
end
|
|
802
1189
|
|
|
@@ -841,7 +1228,7 @@ module LlmWrap
|
|
|
841
1228
|
# image in Claude Code, which puts no typed text on the wire at all.
|
|
842
1229
|
if bits & CTRL != 0
|
|
843
1230
|
case code
|
|
844
|
-
when 117, 99 then
|
|
1231
|
+
when 117, 99 then clear_line # ctrl-u, ctrl-c
|
|
845
1232
|
when 119 then drop_word # ctrl-w
|
|
846
1233
|
end
|
|
847
1234
|
return false
|
|
@@ -865,13 +1252,48 @@ module LlmWrap
|
|
|
865
1252
|
end
|
|
866
1253
|
|
|
867
1254
|
def append_byte(byte)
|
|
1255
|
+
return if byte == BANG && !bang_char
|
|
1256
|
+
|
|
868
1257
|
@buf << byte if @buf.bytesize < MAX_LEN
|
|
869
1258
|
end
|
|
870
1259
|
|
|
871
1260
|
def append(str)
|
|
1261
|
+
return if str == '!' && !bang_char
|
|
1262
|
+
|
|
872
1263
|
@buf << str.b if @buf.bytesize < MAX_LEN
|
|
873
1264
|
end
|
|
874
1265
|
|
|
1266
|
+
# A ! in the first column is ours: from here to Enter nothing reaches the
|
|
1267
|
+
# child and the line runs in a shell instead. Typed twice it is handed back,
|
|
1268
|
+
# so `!!ls` arrives at the agent as `!ls` and its own bash mode is still
|
|
1269
|
+
# there when that is what you wanted. A leading space is the other way out.
|
|
1270
|
+
#
|
|
1271
|
+
# False when the character has been dealt with and must not be appended.
|
|
1272
|
+
def bang_char
|
|
1273
|
+
if @bang && @buf == '!'.b
|
|
1274
|
+
@bang = false # the second !: this one is the child's, and the
|
|
1275
|
+
return false # buffer is already holding a ! for it
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
@bang = true if @buf.empty? && !@paste
|
|
1279
|
+
true
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
def clear_line
|
|
1283
|
+
end_bang
|
|
1284
|
+
@buf = binary
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1287
|
+
# Ending a ! line takes the keystroke that ended it along: a backspace or a
|
|
1288
|
+
# ctrl-c that cancelled one was editing our line and not the child's, and a
|
|
1289
|
+
# ctrl-c in particular would interrupt the agent over nothing.
|
|
1290
|
+
def end_bang
|
|
1291
|
+
return unless @bang
|
|
1292
|
+
|
|
1293
|
+
@bang = false
|
|
1294
|
+
@swallow = true
|
|
1295
|
+
end
|
|
1296
|
+
|
|
875
1297
|
# Drop one whole character: back over any UTF-8 continuation bytes first.
|
|
876
1298
|
def chop_char
|
|
877
1299
|
return if @buf.empty?
|
|
@@ -879,6 +1301,7 @@ module LlmWrap
|
|
|
879
1301
|
i = @buf.bytesize - 1
|
|
880
1302
|
i -= 1 while i.positive? && (@buf.getbyte(i) & 0xc0) == 0x80
|
|
881
1303
|
@buf.slice!(i..)
|
|
1304
|
+
end_bang if @buf.empty?
|
|
882
1305
|
end
|
|
883
1306
|
|
|
884
1307
|
# Readline's unix-word-rubout: eat trailing whitespace, then the word, and
|
|
@@ -886,11 +1309,21 @@ module LlmWrap
|
|
|
886
1309
|
def drop_word
|
|
887
1310
|
@buf.sub!(/\s+\z/, '')
|
|
888
1311
|
@buf.sub!(/\S+\z/, '')
|
|
1312
|
+
end_bang if @buf.empty?
|
|
889
1313
|
end
|
|
890
1314
|
|
|
891
1315
|
def submit
|
|
892
1316
|
text = decode(@buf)
|
|
893
|
-
|
|
1317
|
+
bang = @bang
|
|
1318
|
+
raw = @buf
|
|
1319
|
+
clear_line
|
|
1320
|
+
|
|
1321
|
+
# A ! line is not a prompt: it was never said to the agent, so it has no
|
|
1322
|
+
# business in a history of what was.
|
|
1323
|
+
if bang
|
|
1324
|
+
@bangs << command(raw)
|
|
1325
|
+
return true
|
|
1326
|
+
end
|
|
894
1327
|
|
|
895
1328
|
return false if text.empty? || @prompts.first == text
|
|
896
1329
|
|
|
@@ -899,6 +1332,12 @@ module LlmWrap
|
|
|
899
1332
|
true
|
|
900
1333
|
end
|
|
901
1334
|
|
|
1335
|
+
# The line as a shell should see it, which is not how the bar sees it:
|
|
1336
|
+
# newlines are separators a shell understands and must survive whole.
|
|
1337
|
+
def command(bytes)
|
|
1338
|
+
bytes.dup.force_encoding(Encoding::UTF_8).scrub('').strip.delete_prefix('!').strip
|
|
1339
|
+
end
|
|
1340
|
+
|
|
902
1341
|
# A bar row is one line, so a multi-line prompt - a continuation with
|
|
903
1342
|
# Shift/Option+Enter, or a pasted block - is flattened onto it: line breaks
|
|
904
1343
|
# show as a backslash + space, and clip() caps the result to the terminal
|
|
@@ -910,34 +1349,5 @@ module LlmWrap
|
|
|
910
1349
|
text.gsub(/[^\S\n]+/, ' ') # runs of spaces/tabs, newlines kept
|
|
911
1350
|
.gsub(/ ?\n+ ?/) { '\ ' } # block form: no backslash escaping here
|
|
912
1351
|
end
|
|
913
|
-
|
|
914
|
-
def clip(text, cells)
|
|
915
|
-
return '' if cells <= 0
|
|
916
|
-
return text if width(text) <= cells
|
|
917
|
-
|
|
918
|
-
out = +''
|
|
919
|
-
used = 0
|
|
920
|
-
text.each_char do |ch|
|
|
921
|
-
w = char_width(ch)
|
|
922
|
-
break if used + w > cells - 1
|
|
923
|
-
|
|
924
|
-
out << ch
|
|
925
|
-
used += w
|
|
926
|
-
end
|
|
927
|
-
out << '…'
|
|
928
|
-
end
|
|
929
|
-
|
|
930
|
-
def width(text)
|
|
931
|
-
text.each_char.sum { |ch| char_width(ch) }
|
|
932
|
-
end
|
|
933
|
-
|
|
934
|
-
def char_width(char)
|
|
935
|
-
cp = char.ord
|
|
936
|
-
return 0 if cp == 0x200d || ZERO.any? { |r| r.cover?(cp) }
|
|
937
|
-
|
|
938
|
-
WIDE.any? { |r| r.cover?(cp) } ? 2 : 1
|
|
939
|
-
rescue RangeError
|
|
940
|
-
1
|
|
941
|
-
end
|
|
942
1352
|
end
|
|
943
1353
|
end
|
data/recipes/llm.rb
CHANGED
|
@@ -9,6 +9,7 @@ desc <<~TXT
|
|
|
9
9
|
memory persistent memory store (backs the Claude Code memory plugin)
|
|
10
10
|
plan apply a /plan bundle - sha1 checked, drift aware
|
|
11
11
|
prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
|
|
12
|
+
todo per-project task queue for agents (./LLM_TODO.local.md)
|
|
12
13
|
|
|
13
14
|
Commands:
|
|
14
15
|
usage subscription limits and per-model token usage
|
|
@@ -100,6 +101,17 @@ task :wrap do
|
|
|
100
101
|
inserts for you (history recall, autocomplete, menu picks) will not show up, and
|
|
101
102
|
nothing is captured while the program has echo off in canonical mode (sudo, ssh).
|
|
102
103
|
|
|
104
|
+
A line starting with ! is taken over: nothing reaches the wrapped program, the line
|
|
105
|
+
runs in $SHELL here, and the bar grows to show where it ran, what ran and what it
|
|
106
|
+
printed. That is a shell round trip instead of a tool call, a permission check and a
|
|
107
|
+
model turn - and it costs no context either, since the agent is never told.
|
|
108
|
+
|
|
109
|
+
Output stays up until you type anything at all, and a bare ! clears it too. Long
|
|
110
|
+
output is cut to the rows there are, and a command still running after 10s is killed,
|
|
111
|
+
since the wrapper is stopped while one runs. `cd` is handled here so that it sticks,
|
|
112
|
+
and moves nothing but the wrapper. Type !! to send a literal ! to the program instead,
|
|
113
|
+
which is how you reach Claude Code's own bash mode; a leading space does the same.
|
|
114
|
+
|
|
103
115
|
Keys are decoded from plain bytes, the kitty keyboard protocol and xterm
|
|
104
116
|
modifyOtherKeys, since a program can ask the terminal for any of the three (Claude
|
|
105
117
|
Code turns on the last two). Mouse reports, focus events, arrows and Ctrl-<key> are
|
|
@@ -321,6 +333,287 @@ namespace :memory do
|
|
|
321
333
|
end
|
|
322
334
|
end
|
|
323
335
|
|
|
336
|
+
TODO_GUIDE ||= <<~TXT
|
|
337
|
+
Per-project task queue in ./LLM_TODO.local.md - workable by humans and any agent.
|
|
338
|
+
|
|
339
|
+
Human usage:
|
|
340
|
+
llm todo:add "fix login redirect" queue a task (pipe stdin for bulk / multi-line)
|
|
341
|
+
llm todo:list tasks by id, counts, format warnings
|
|
342
|
+
llm todo:pop + llm todo:done take / finish a single task by hand
|
|
343
|
+
|
|
344
|
+
Edit the file freely: only the `# todo`, `# doing` and `# done` sections are
|
|
345
|
+
managed. `# plan`, `# idea` and any prose stay untouched. A task starts with
|
|
346
|
+
`*` at the line start and runs until the next `*` or header; when finished it
|
|
347
|
+
moves to `# done` verbatim.
|
|
348
|
+
|
|
349
|
+
LLM usage - one of these lines is the whole prompt:
|
|
350
|
+
"Run `llm todo go` and follow its output." work through every task
|
|
351
|
+
"Run `llm todo pop`, do the task, run `llm todo done`." do a single task
|
|
352
|
+
|
|
353
|
+
Task text is printed raw on stdout. `todo go` repeats the protocol after each
|
|
354
|
+
task and says when the queue is empty; `todo pop` re-prints the task in
|
|
355
|
+
progress, so an interrupted agent resumes instead of skipping ahead.
|
|
356
|
+
TXT
|
|
357
|
+
|
|
358
|
+
task :todo do
|
|
359
|
+
desc <<~D
|
|
360
|
+
How the todo queue works, for humans and LLMs.
|
|
361
|
+
|
|
362
|
+
#{TODO_GUIDE}
|
|
363
|
+
D
|
|
364
|
+
example 'todo'
|
|
365
|
+
|
|
366
|
+
proc do
|
|
367
|
+
say TODO_GUIDE
|
|
368
|
+
say ''
|
|
369
|
+
self.class.print_help 'todo:'
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
namespace :todo do
|
|
374
|
+
# Same story as :memory - helpers must live inside the namespace block.
|
|
375
|
+
private
|
|
376
|
+
|
|
377
|
+
TODO_FILE ||= 'LLM_TODO.local.md'
|
|
378
|
+
TODO_SECTIONS ||= { todo: 'todo', doing: 'doing', done: 'done' }.freeze
|
|
379
|
+
|
|
380
|
+
def todo_path
|
|
381
|
+
File.join(Dir.pwd, TODO_FILE)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Exact title match on purpose - "# done ideas" must stay an unmanaged section.
|
|
385
|
+
def todo_section_key(title)
|
|
386
|
+
case title.strip.downcase
|
|
387
|
+
when 'todo', 'pending' then :todo
|
|
388
|
+
when 'doing', 'in progress', 'in-progress' then :doing
|
|
389
|
+
when 'done', 'completed' then :done
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# The tool manages only the `# todo`, `# doing` and `# done` sections (any
|
|
394
|
+
# heading level). Inside them a task starts at a `*` or `-` bullet in the
|
|
395
|
+
# first column and runs until the next bullet or header, so tasks can span
|
|
396
|
+
# multiple lines and move between sections verbatim. Every other section
|
|
397
|
+
# (`# plan`, `# idea`, ...) and any preamble is kept as-is and never touched.
|
|
398
|
+
# Returns [tasks, nodes]; nodes reproduce the document layout for todo_save.
|
|
399
|
+
def todo_parse
|
|
400
|
+
tasks = { todo: [], doing: [], done: [] }
|
|
401
|
+
nodes = []
|
|
402
|
+
return [tasks, nodes] unless File.file?(todo_path)
|
|
403
|
+
|
|
404
|
+
section = nil # managed section key, nil while inside a raw chunk
|
|
405
|
+
current = nil # lines of the task being collected
|
|
406
|
+
|
|
407
|
+
flush_task = lambda do
|
|
408
|
+
tasks[section] << current.join("\n").rstrip if current
|
|
409
|
+
current = nil
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
File.foreach(todo_path) do |raw|
|
|
413
|
+
line = raw.chomp
|
|
414
|
+
if (m = line.match(/^#+\s*(\S.*?)\s*$/))
|
|
415
|
+
flush_task.call
|
|
416
|
+
section = todo_section_key(m[1])
|
|
417
|
+
if section
|
|
418
|
+
nodes << [:section, section] unless nodes.include?([:section, section])
|
|
419
|
+
else
|
|
420
|
+
nodes << [:raw, [line]]
|
|
421
|
+
end
|
|
422
|
+
elsif section
|
|
423
|
+
if line =~ /^[-*]\s+(.*)$/
|
|
424
|
+
flush_task.call
|
|
425
|
+
current = [$1.strip]
|
|
426
|
+
elsif current
|
|
427
|
+
current << line.rstrip
|
|
428
|
+
end
|
|
429
|
+
else
|
|
430
|
+
nodes << [:raw, []] unless nodes.last && nodes.last[0] == :raw
|
|
431
|
+
nodes.last[1] << line.rstrip
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
flush_task.call
|
|
435
|
+
[tasks, nodes]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Rewrite: managed sections in canonical form, raw chunks verbatim, original
|
|
439
|
+
# order kept. Managed sections missing from the file are appended at the end.
|
|
440
|
+
def todo_save(tasks, nodes)
|
|
441
|
+
order = nodes + (TODO_SECTIONS.keys - nodes.map { |t, v| v if t == :section }).map { |k| [:section, k] }
|
|
442
|
+
|
|
443
|
+
File.open(todo_path, 'w') do |io|
|
|
444
|
+
order.each_with_index do |(type, value), i|
|
|
445
|
+
io.puts unless i.zero?
|
|
446
|
+
if type == :section
|
|
447
|
+
io.puts "# #{TODO_SECTIONS[value]}"
|
|
448
|
+
tasks[value].each do |text|
|
|
449
|
+
io.puts
|
|
450
|
+
io.puts "* #{text}"
|
|
451
|
+
end
|
|
452
|
+
else
|
|
453
|
+
lines = value.dup
|
|
454
|
+
lines.pop while lines.any? && lines.last.empty?
|
|
455
|
+
lines.each { |l| io.puts l }
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# One-line label for confirmations - multi-line tasks show their first line.
|
|
462
|
+
def todo_label(text)
|
|
463
|
+
first, rest = text.split("\n", 2)
|
|
464
|
+
rest ? "#{first} ..." : first
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Format sanity check for hand-edited files. Returns warning strings.
|
|
468
|
+
def todo_lint(tasks)
|
|
469
|
+
warns = []
|
|
470
|
+
seen = Hash.new(0)
|
|
471
|
+
section = :preamble
|
|
472
|
+
File.foreach(todo_path) do |raw|
|
|
473
|
+
line = raw.chomp
|
|
474
|
+
if (m = line.match(/^#+\s*(\S.*?)\s*$/))
|
|
475
|
+
key = todo_section_key(m[1])
|
|
476
|
+
section = key || :other
|
|
477
|
+
seen[key] += 1 if key
|
|
478
|
+
elsif section == :preamble && line =~ /^[-*]\s/
|
|
479
|
+
warns << "ignored bullet before any section header: #{line}"
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
TODO_SECTIONS.each_key do |key|
|
|
483
|
+
warns << "duplicate '# #{key}' sections - their tasks are merged" if seen[key] > 1
|
|
484
|
+
warns << "missing '# #{key}' section - will be added on next write" if seen[key].zero?
|
|
485
|
+
end
|
|
486
|
+
warns << "#{tasks[:doing].length} tasks in doing - expected at most 1" if tasks[:doing].length > 1
|
|
487
|
+
warns
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
task :add do
|
|
491
|
+
desc <<~DESC
|
|
492
|
+
Add task(s) to ./#{TODO_FILE} (created on first add).
|
|
493
|
+
|
|
494
|
+
Text comes from the argument (one task), or from stdin - there a `*` at
|
|
495
|
+
the start of a line begins a new task and following lines belong to it,
|
|
496
|
+
so multi-line tasks and whole lists can be piped in at once.
|
|
497
|
+
DESC
|
|
498
|
+
example 'todo add "migrate the user model to Sequel"'
|
|
499
|
+
example 'cat tasks.md | llm todo add'
|
|
500
|
+
opt :text, desc: 'task text (or pipe tasks on stdin)'
|
|
501
|
+
|
|
502
|
+
proc do |opts|
|
|
503
|
+
# unquoted multi-word input lands in :text + :args - join it back
|
|
504
|
+
arg = [opts[:text], *opts[:args]].compact.join(' ').strip
|
|
505
|
+
if arg.empty?
|
|
506
|
+
new_tasks = []
|
|
507
|
+
opts[:stdin].to_s.split("\n").each do |line|
|
|
508
|
+
if line =~ /^[-*]\s+(.*)$/
|
|
509
|
+
new_tasks << [$1.strip]
|
|
510
|
+
elsif new_tasks.any?
|
|
511
|
+
new_tasks.last << line.rstrip
|
|
512
|
+
elsif line.strip != ''
|
|
513
|
+
new_tasks << [line.rstrip]
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
new_tasks = new_tasks.map { |lines| lines.join("\n").rstrip }.reject(&:empty?)
|
|
517
|
+
else
|
|
518
|
+
new_tasks = [arg]
|
|
519
|
+
end
|
|
520
|
+
error 'usage: llm todo add <text> (or pipe tasks on stdin)' if new_tasks.empty?
|
|
521
|
+
|
|
522
|
+
tasks, nodes = todo_parse
|
|
523
|
+
tasks[:todo].concat new_tasks
|
|
524
|
+
todo_save tasks, nodes
|
|
525
|
+
new_tasks.each { |t| say "added: #{todo_label(t)}", :green }
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
task :pop do
|
|
530
|
+
desc <<~DESC
|
|
531
|
+
Print the current task and stop there - one task, no loop.
|
|
532
|
+
|
|
533
|
+
Moves the first todo task to doing if nothing is doing yet. Prints only
|
|
534
|
+
the raw task text (agent-friendly stdout). Re-running without `todo done`
|
|
535
|
+
prints the same task again, so an interrupted agent resumes instead of
|
|
536
|
+
starting the next one. Exits 1 when the list is empty.
|
|
537
|
+
DESC
|
|
538
|
+
example 'todo pop'
|
|
539
|
+
|
|
540
|
+
proc do
|
|
541
|
+
tasks, nodes = todo_parse
|
|
542
|
+
if tasks[:doing].empty?
|
|
543
|
+
error 'todo list is empty' if tasks[:todo].empty?
|
|
544
|
+
tasks[:doing] << tasks[:todo].shift
|
|
545
|
+
todo_save tasks, nodes
|
|
546
|
+
end
|
|
547
|
+
puts tasks[:doing].first
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
task :go do
|
|
552
|
+
desc <<~DESC
|
|
553
|
+
Agent entry point: work through the whole todo list.
|
|
554
|
+
|
|
555
|
+
Prints the current task (pop) followed by the loop protocol, so telling
|
|
556
|
+
any agent "run llm todo go" is enough to drain the queue. When the list
|
|
557
|
+
is empty it says so and tells the agent to stop and summarize.
|
|
558
|
+
DESC
|
|
559
|
+
example 'todo go'
|
|
560
|
+
|
|
561
|
+
proc do
|
|
562
|
+
tasks, nodes = todo_parse
|
|
563
|
+
if tasks[:doing].empty? && tasks[:todo].empty?
|
|
564
|
+
say 'All tasks done - stop the loop and summarize what was done.'
|
|
565
|
+
next
|
|
566
|
+
end
|
|
567
|
+
if tasks[:doing].empty?
|
|
568
|
+
tasks[:doing] << tasks[:todo].shift
|
|
569
|
+
todo_save tasks, nodes
|
|
570
|
+
end
|
|
571
|
+
say "TASK: #{tasks[:doing].first}"
|
|
572
|
+
say ''
|
|
573
|
+
say 'Do this task fully and verify it. Then run `llm todo done`, and `llm todo go` for the next one.'
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
task :done do
|
|
578
|
+
desc 'Mark the in-progress task as done'
|
|
579
|
+
example 'todo done'
|
|
580
|
+
|
|
581
|
+
proc do
|
|
582
|
+
tasks, nodes = todo_parse
|
|
583
|
+
error 'no task in progress (run: llm todo pop)' if tasks[:doing].empty?
|
|
584
|
+
text = tasks[:doing].shift
|
|
585
|
+
tasks[:done] << text
|
|
586
|
+
todo_save tasks, nodes
|
|
587
|
+
say "done: #{todo_label(text)}", :green
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
task :list do
|
|
592
|
+
alt :inspect
|
|
593
|
+
desc 'Inspect the todo file: tasks by id, counts, format validity warnings'
|
|
594
|
+
example 'todo list'
|
|
595
|
+
|
|
596
|
+
proc do
|
|
597
|
+
error "no #{TODO_FILE} in #{Dir.pwd} (run: llm todo add <text>)" unless File.file?(todo_path)
|
|
598
|
+
tasks, = todo_parse
|
|
599
|
+
id = 0
|
|
600
|
+
TODO_SECTIONS.each do |key, title|
|
|
601
|
+
say "#{title}:", :cyan
|
|
602
|
+
color = { todo: nil, doing: :yellow, done: :gray }[key]
|
|
603
|
+
tasks[key].each do |text|
|
|
604
|
+
first, *rest = text.split("\n")
|
|
605
|
+
say "#{(id += 1).to_s.rjust(3)}. #{first}", color
|
|
606
|
+
rest.each { |l| say " #{l}", color }
|
|
607
|
+
end
|
|
608
|
+
say ' (none)', :gray if tasks[key].empty?
|
|
609
|
+
end
|
|
610
|
+
say ''
|
|
611
|
+
say "#{tasks[:todo].length} todo, #{tasks[:doing].length} doing, #{tasks[:done].length} done"
|
|
612
|
+
todo_lint(tasks).each { |w| say "warning: #{w}", :yellow }
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
324
617
|
namespace :prompt do
|
|
325
618
|
TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
|
|
326
619
|
TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lux-hammer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.23
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dino Reic
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|