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 +4 -4
- data/.version +1 -1
- data/recipes/deploy.rb +15 -7
- data/recipes/lib/llm/wrap.rb +197 -6
- data/recipes/llm.rb +1 -1
- metadata +2 -11
- data/recipes/lib/deploy/boot.rb +0 -52
- data/recipes/lib/deploy/commands.rb +0 -555
- data/recipes/lib/deploy/config.rb +0 -62
- data/recipes/lib/deploy/context.rb +0 -149
- data/recipes/lib/deploy/doctor.rb +0 -238
- data/recipes/lib/deploy/hammer.rb +0 -168
- data/recipes/lib/deploy/manifest.rb +0 -169
- data/recipes/lib/deploy/ssh.rb +0 -129
- data/recipes/lib/deploy/template.rb +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84a9d6339d8a34112c31a210823398ea9122959fff9d3522562f840147e8a130
|
|
4
|
+
data.tar.gz: f7483553452c4e33d18dad6d9d4e2b764cb13a49a4080ea90f4227689f9fc211
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5158a762b7cb292535f58e33b0a8ae84c2f9affe473501cce701784d5a690bebf5c187e3c588df66821a5cd3f3177f83d655149e84426ea8e9a12c578e7e5c8
|
|
7
|
+
data.tar.gz: 5711d908d489770a4ede0507224f0a6cf3dbe761edddaed0f6c4591b0e1a1e070e4727f8000829c861cc215be8baf238933b6591d996bb8ee500742d70d22aa5
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
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
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
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
|
-
#
|
|
31
|
-
#
|
|
32
|
-
LuxDeploy::Hammer.register(self
|
|
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)
|
data/recipes/lib/llm/wrap.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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-
|
|
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"
|
data/recipes/lib/deploy/boot.rb
DELETED
|
@@ -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'
|