lux-hammer 0.3.20 → 0.3.21

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: 0547c6b7b08fe92297694e5d3f19d4726bc3ec3781e9a7169bfeefd1191f7cdd
4
- data.tar.gz: e2c8bd882af53a49a24e881ec2329241430c63ec1d6a9f90dd310c4e258364ed
3
+ metadata.gz: 84a9d6339d8a34112c31a210823398ea9122959fff9d3522562f840147e8a130
4
+ data.tar.gz: f7483553452c4e33d18dad6d9d4e2b764cb13a49a4080ea90f4227689f9fc211
5
5
  SHA512:
6
- metadata.gz: 94bf60197a56713381b1201a8344941595083770c4b3d8d753b624d455326f7cf5208923d49f3046d436bbfe560f28bbfc97bcfe44f5cf4fe85657b856e5f9e8
7
- data.tar.gz: 5d5d8d807fd5dee383a528d0e8b38d8a275b4d553ac873c30e703641ed6d85d2f203fbd604384624c25c03f4971b4eb51e3d9c28145a0af06f5067cb189083f9
6
+ metadata.gz: e5158a762b7cb292535f58e33b0a8ae84c2f9affe473501cce701784d5a690bebf5c187e3c588df66821a5cd3f3177f83d655149e84426ea8e9a12c578e7e5c8
7
+ data.tar.gz: 5711d908d489770a4ede0507224f0a6cf3dbe761edddaed0f6c4591b0e1a1e070e4727f8000829c861cc215be8baf238933b6591d996bb8ee500742d70d22aa5
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.20
1
+ 0.3.21
data/recipes/deploy.rb CHANGED
@@ -16,9 +16,17 @@ desc <<~TXT
16
16
  deploy log --log errors # dump a remote log
17
17
  TXT
18
18
 
19
- # Loads the bundled lib (config/ssh/template/doctor/context/manifest/
20
- # commands/hammer) - same require chain the gem's lib/lux_deploy.rb had.
21
- require_relative 'lib/deploy/boot'
19
+ # The engine lives in the lux-deploy gem. This recipe used to carry a vendored
20
+ # copy of it, which drifted a full major version behind and would have deployed
21
+ # 0.2 semantics onto a 0.3 host. Requiring the gem keeps one engine.
22
+ #
23
+ # Not a gemspec dependency - lux-hammer stays zero-dependency, and only this
24
+ # one recipe needs it. The require is what enforces it, at invocation time.
25
+ begin
26
+ require 'lux_deploy'
27
+ rescue LoadError
28
+ abort 'lux-deploy is not installed. Run: gem install lux-deploy'
29
+ end
22
30
 
23
31
  # Auto-load the app's deploy bootstrap, if present, before tasks fire.
24
32
  # A consumer can inject Ruby (e.g. a pre-deploy hook) without writing a
@@ -26,7 +34,7 @@ require_relative 'lib/deploy/boot'
26
34
  init = File.join(Dir.pwd, 'config', 'deploy', 'init.rb')
27
35
  load init if File.file?(init)
28
36
 
29
- # `self` here is the recipe's Builder context - same surface a Hammerfile
30
- # gets. Pass templates_dir explicitly since there's no gem ROOT to fall
31
- # back to; it resolves to recipes/lib/deploy/templates.
32
- LuxDeploy::Hammer.register(self, templates_dir: File.join(__dir__, 'lib/deploy/templates'))
37
+ # `self` here is the recipe's Builder context - same surface a Hammerfile gets.
38
+ # No templates_dir: the gem falls back to its own LuxDeploy::ROOT/templates,
39
+ # which is what the standalone `lux-deploy` binary uses too.
40
+ LuxDeploy::Hammer.register(self)
@@ -36,6 +36,20 @@ module LlmWrap
36
36
  MAX_DEFER ||= 0.15
37
37
  CHUNK ||= 65_536
38
38
 
39
+ # A pty master hands back at most a kilobyte per read whatever CHUNK says, so
40
+ # one redraw arrives as dozens of pieces. DRAIN_MAX caps how much of it we
41
+ # take in a single go, so a child that never stops talking cannot starve the
42
+ # keyboard. MAX_BLOCK is how long a paint will wait for a safe moment before
43
+ # giving up and taking one - see Session#paint.
44
+ DRAIN_MAX ||= 262_144
45
+ MAX_BLOCK ||= 1.5
46
+
47
+ # Introducers of the escape sequences that carry a string payload:
48
+ # OSC ], DCS P, SOS X, PM ^, APC _. They run to BEL or ST rather than to a
49
+ # final byte, so both stream parsers here - KeyBuffer on the way in, OutScan
50
+ # on the way out - have to know them to find the end of one.
51
+ STRING_INTRO ||= [0x5d, 0x50, 0x58, 0x5e, 0x5f].freeze
52
+
39
53
  # `origin` is how this wrapper was asked for, in the words that would ask for
40
54
  # it again - only the caller knows that, since taking the child's name throws
41
55
  # our own command line away. It is what Handoff writes down; the child's argv
@@ -143,9 +157,12 @@ module LlmWrap
143
157
  @origin = origin
144
158
  @bar_rows = keep + 1 # the prompts, plus the rule above them
145
159
  @keys = KeyBuffer.new(keep)
160
+ @out = OutScan.new
146
161
  @winch = false
147
162
  @dirty = true
148
163
  @drawn_at = 0.0
164
+ @blocked = nil
165
+ @skipped = nil
149
166
  @started = now
150
167
  @trace = (File.open(ENV['LLM_WRAP_DEBUG'], 'a') if ENV['LLM_WRAP_DEBUG'].to_s != '')
151
168
  rescue SystemCallError
@@ -281,7 +298,7 @@ module LlmWrap
281
298
  ready = IO.select([$stdin, @pty_out], nil, nil, IDLE_TICK)
282
299
 
283
300
  unless ready
284
- draw_bar if @dirty # output settled
301
+ paint if @dirty # output settled
285
302
  next
286
303
  end
287
304
 
@@ -293,8 +310,9 @@ module LlmWrap
293
310
  @dirty = true if taking && @keys.feed(data)
294
311
  trace_in(data, taking)
295
312
  else
296
- data = slurp(@pty_out) or return
313
+ data = drain(@pty_out) or return
297
314
  $stdout.write(data)
315
+ @out.feed(data)
298
316
  trace_out(data)
299
317
  # The child cannot address the bar rows, but it can switch to the
300
318
  # alt screen (which starts blank) or scroll a region it set itself.
@@ -304,7 +322,7 @@ module LlmWrap
304
322
  end
305
323
 
306
324
  # Keep the bar honest while output streams without ever settling.
307
- draw_bar if @dirty && now - @drawn_at > MAX_DEFER
325
+ paint if @dirty && now - @drawn_at > MAX_DEFER
308
326
  end
309
327
  rescue Errno::EIO, Errno::EPIPE, EOFError
310
328
  nil
@@ -316,6 +334,49 @@ module LlmWrap
316
334
  nil
317
335
  end
318
336
 
337
+ # Take everything the child has queued, not just the kilobyte a pty master
338
+ # gives back per read. One redraw then lands in a single write and the paint
339
+ # that follows it falls on a frame boundary instead of inside the frame.
340
+ def drain(io)
341
+ data = slurp(io) or return nil
342
+
343
+ while data.bytesize < DRAIN_MAX && IO.select([io], nil, nil, 0)
344
+ more = slurp(io) or break
345
+ data << more
346
+ end
347
+
348
+ data
349
+ end
350
+
351
+ # Paint only where the terminal's parser is at rest - see OutScan for what
352
+ # goes wrong otherwise. The bar is cosmetic, so waiting is nearly always
353
+ # right; the exception is a child that leaves a sequence open for good (an
354
+ # unterminated OSC, a DECSC it never restores), where a bar frozen on the
355
+ # wrong prompts is worse than one glitched frame.
356
+ def paint
357
+ return released if @out.safe?
358
+
359
+ @blocked ||= now
360
+
361
+ if now - @blocked < MAX_BLOCK
362
+ # Once per state rather than once per tick, or the log is nothing else.
363
+ reason = @out.to_s
364
+ trace('SKIP', reason) if reason != @skipped
365
+ @skipped = reason
366
+ return
367
+ end
368
+
369
+ trace('FORCE', @out.to_s)
370
+ @out.reset!
371
+ released
372
+ end
373
+
374
+ def released
375
+ @blocked = nil
376
+ @skipped = nil
377
+ draw_bar
378
+ end
379
+
319
380
  # Terminals disagree wildly about how they report keys, and the encoding
320
381
  # depends on modes the child turns on and off as it runs. LLM_WRAP_DEBUG=<file>
321
382
  # records both sides raw so the two can be lined up. Off unless asked for -
@@ -368,6 +429,139 @@ module LlmWrap
368
429
  end
369
430
  end
370
431
 
432
+ # Follows the child's output on its way to the screen and answers one
433
+ # question: is the terminal's parser at rest right now?
434
+ #
435
+ # Painting the bar means writing our own escapes into that stream, and a pty
436
+ # master hands back at most a kilobyte per read - so a full redraw arrives as
437
+ # dozens of pieces split at arbitrary bytes, and a paint dropped into one of
438
+ # the seams breaks whatever it landed in the middle of:
439
+ #
440
+ # * a sequence, leaving the introducer stranded and its tail printed as
441
+ # text - the literal "[22m" on screen
442
+ # * a UTF-8 character, which comes out as a replacement glyph
443
+ # * a DECSC the child opened and has not closed yet. The terminal has one
444
+ # save slot; we take it, and the child's own ESC8 then puts its cursor on
445
+ # our bar and it draws the rest of the frame over the top. Claude Code
446
+ # wraps every repaint in ESC7 ... ESC8, so this one is not theoretical.
447
+ #
448
+ # This is only ever asked about the *end* of what we have written so far, so
449
+ # there is no need to understand the sequences - just to know where they stop.
450
+ class OutScan
451
+ ESC = 0x1b
452
+ BEL = 0x07
453
+
454
+ # Escapes whose second byte is followed by exactly one more: SS3 (ESC O),
455
+ # the charset designators (ESC ( ) * +), and ESC # / ESC %.
456
+ ONE_MORE ||= [0x4f, 0x28, 0x29, 0x2a, 0x2b, 0x23, 0x25].freeze
457
+
458
+ # Runaway guards. A CSI this long, or a string payload this long, is a
459
+ # stream we have lost the thread of rather than a sequence still coming.
460
+ MAX_CSI ||= 128
461
+ MAX_STR ||= 8192
462
+
463
+ # Nothing outside these bytes can start a sequence or a multi-byte
464
+ # character, so a plain-text burst needs no walking at all.
465
+ INTERESTING ||= /[\e\x80-\xff]/n
466
+
467
+ def initialize
468
+ reset!
469
+ end
470
+
471
+ def reset!
472
+ @state = :text
473
+ @need = 0 # UTF-8 continuation bytes still owed
474
+ @saved = 0 # ESC7 seen without its ESC8
475
+ @len = 0
476
+ end
477
+
478
+ # True when the last byte written ended a sequence and a character, and the
479
+ # child is not holding a saved cursor.
480
+ def safe?
481
+ @state == :text && @need.zero? && @saved.zero?
482
+ end
483
+
484
+ def feed(bytes)
485
+ return if @state == :text && @need.zero? && !bytes.match?(INTERESTING)
486
+
487
+ bytes.each_byte { |b| step(b) }
488
+ end
489
+
490
+ # What is holding a paint back, for the debug trace.
491
+ def to_s
492
+ "#{@state} utf8=#{@need} saved=#{@saved}"
493
+ end
494
+
495
+ private
496
+
497
+ def step(byte)
498
+ case @state
499
+ when :text then text(byte)
500
+ when :esc then escape(byte)
501
+ when :csi then csi(byte)
502
+ when :one then @state = :text
503
+ when :str then string(byte)
504
+ when :st then terminator(byte)
505
+ end
506
+ end
507
+
508
+ def text(byte)
509
+ return @state = :esc if byte == ESC
510
+ return @need = 0 if byte < 0x80
511
+
512
+ # A continuation byte only counts while one is owed; anything else is a
513
+ # lead byte, and a stray one just resets the count.
514
+ return @need -= 1 if @need.positive? && (byte & 0xc0) == 0x80
515
+
516
+ @need = case byte
517
+ when 0xc0..0xdf then 1
518
+ when 0xe0..0xef then 2
519
+ when 0xf0..0xf7 then 3
520
+ else 0
521
+ end
522
+ end
523
+
524
+ # The second byte says how the rest of the sequence ends. ESC7/ESC8 are the
525
+ # pair we care about beyond that - see the class comment.
526
+ def escape(byte)
527
+ @len = 0
528
+
529
+ case byte
530
+ when 0x5b then @state = :csi
531
+ when *STRING_INTRO then @state = :str
532
+ when *ONE_MORE then @state = :one
533
+ when ESC then nil # ESC ESC: still :esc
534
+ else
535
+ # ESC7 saves the cursor and ESC8 restores it. Everything else that gets
536
+ # here is a two-byte escape, and is over.
537
+ @saved += 1 if byte == 0x37
538
+ @saved -= 1 if byte == 0x38 && @saved.positive?
539
+ @state = :text
540
+ end
541
+ end
542
+
543
+ def csi(byte)
544
+ @len += 1
545
+ return @state = :esc if byte == ESC # aborted, a new one starting
546
+ return @state = :text if @len > MAX_CSI
547
+
548
+ @state = :text if byte >= 0x40 && byte <= 0x7e
549
+ end
550
+
551
+ # String payloads run to BEL or ST (ESC \).
552
+ def string(byte)
553
+ @len += 1
554
+ return @state = :st if byte == ESC
555
+ return @state = :text if byte == BEL || @len > MAX_STR
556
+ end
557
+
558
+ def terminator(byte)
559
+ return if byte == ESC
560
+
561
+ @state = byte == 0x5c ? :text : :str
562
+ end
563
+ end
564
+
371
565
  # Rebuilds the line you are typing from the raw byte stream on its way to the
372
566
  # child, and keeps the last `keep` submitted lines.
373
567
  #
@@ -387,9 +581,6 @@ module LlmWrap
387
581
  BEL = 0x07
388
582
  DEL = 0x7f
389
583
 
390
- # Introducers of the escape sequences that carry a string payload:
391
- # OSC ], DCS P, SOS X, PM ^, APC _.
392
- STRING_INTRO ||= [0x5d, 0x50, 0x58, 0x5e, 0x5f].freeze
393
584
  CTRL_C = 0x03
394
585
  CTRL_U = 0x15
395
586
  CTRL_W = 0x17
data/recipes/llm.rb CHANGED
@@ -91,7 +91,7 @@ task :wrap do
91
91
  newest one sits on the very last line. It is --lines + 1 rows tall.
92
92
 
93
93
  Each prompt gets one row: a multi-line prompt is flattened onto it with line breaks
94
- shown as a literal \\n, and the whole thing is clipped to the terminal width.
94
+ shown as a backslash, and the whole thing is clipped to the terminal width.
95
95
 
96
96
  A prompt is whatever you type between Enters. Shift/Option+Enter and pasted newlines
97
97
  keep appending to the current one. This is keystroke sniffing, so text the program
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.20
4
+ version: 0.3.21
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-07-26 00:00:00.000000000 Z
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -55,15 +55,6 @@ files:
55
55
  - "./lib/lux-hammer.rb"
56
56
  - "./recipes/deploy.rb"
57
57
  - "./recipes/git-helper.rb"
58
- - "./recipes/lib/deploy/boot.rb"
59
- - "./recipes/lib/deploy/commands.rb"
60
- - "./recipes/lib/deploy/config.rb"
61
- - "./recipes/lib/deploy/context.rb"
62
- - "./recipes/lib/deploy/doctor.rb"
63
- - "./recipes/lib/deploy/hammer.rb"
64
- - "./recipes/lib/deploy/manifest.rb"
65
- - "./recipes/lib/deploy/ssh.rb"
66
- - "./recipes/lib/deploy/template.rb"
67
58
  - "./recipes/lib/llm/usage.rb"
68
59
  - "./recipes/lib/llm/wrap.rb"
69
60
  - "./recipes/llm.rb"
@@ -1,52 +0,0 @@
1
- require 'fileutils'
2
- require 'pathname'
3
- require 'open3'
4
- require 'set'
5
- require 'shellwords'
6
- require 'yaml'
7
-
8
- module LuxDeploy
9
- # Recipe layout: this file and its siblings live under
10
- # recipes/lib/deploy/, so ROOT points here and `templates/` sits
11
- # right next to us (no gem root anymore).
12
- ROOT ||= Pathname.new(__dir__)
13
- VERSION ||= '0.2.0'
14
-
15
- # Branches that select `.env` instead of `.env.staging`.
16
- MAIN_BRANCHES ||= %w[master main]
17
-
18
- # Server-side conventions. Not config-tunable because doctor and the
19
- # deploy flow both hardcode these paths in the host setup. A different
20
- # caddy/systemd layout means a different recipe.
21
- PORT_RANGE ||= (3010..3990).step(10).to_a
22
- CADDY_SITES ||= '/etc/caddy/sites'
23
- SYSTEMD_DIR ||= '/etc/systemd/system'
24
-
25
- class Error < StandardError
26
- def to_s
27
- "ERROR: #{super}"
28
- end
29
- end
30
-
31
- # Host-supplied defaults that sit under the user's .yaml. Set once by a
32
- # wrapping plugin/Hammerfile (e.g. lux-fw seeds 'lux-web' / 'lux-apps'),
33
- # consumed by Config.new. Empty by default so the recipe stays "generic".
34
- @defaults = {}
35
-
36
- class << self
37
- attr_reader :defaults
38
-
39
- def set_defaults(hash)
40
- @defaults = (hash || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
41
- end
42
- end
43
- end
44
-
45
- require_relative 'config'
46
- require_relative 'ssh'
47
- require_relative 'template'
48
- require_relative 'doctor'
49
- require_relative 'context'
50
- require_relative 'manifest'
51
- require_relative 'commands'
52
- require_relative 'hammer'