letsdo 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +11 -4
- data/bin/letsdo +20 -12
- data/lib/letsdo/agent.rb +6 -4
- data/lib/letsdo/agent_loop/tasks.rb +62 -0
- data/lib/letsdo/agent_loop.rb +52 -124
- data/lib/letsdo/backlog_tasks.rb +10 -11
- data/lib/letsdo/capture.rb +104 -0
- data/lib/letsdo/cli/init.rb +33 -0
- data/lib/letsdo/cli/launch.rb +87 -0
- data/lib/letsdo/cli.rb +64 -124
- data/lib/letsdo/control.rb +39 -0
- data/lib/letsdo/default_prompt.rb +69 -0
- data/lib/letsdo/errors.rb +2 -12
- data/lib/letsdo/loop.rb +31 -15
- data/lib/letsdo/output_streamer/arg_summary.rb +81 -0
- data/lib/letsdo/output_streamer.rb +21 -214
- data/lib/letsdo/pi_runner/events.rb +75 -0
- data/lib/letsdo/pi_runner/process.rb +68 -0
- data/lib/letsdo/pi_runner.rb +62 -221
- data/lib/letsdo/prompt_store.rb +47 -8
- data/lib/letsdo/tui/input.rb +4 -4
- data/lib/letsdo/tui/log_buffer.rb +3 -3
- data/lib/letsdo/tui/metrics.rb +1 -1
- data/lib/letsdo/tui/renderer.rb +103 -80
- data/lib/letsdo/tui/session/keys.rb +121 -0
- data/lib/letsdo/tui/session/view.rb +87 -0
- data/lib/letsdo/tui/session.rb +48 -181
- data/lib/letsdo/tui/terminal.rb +26 -8
- data/lib/letsdo/tui.rb +6 -6
- data/lib/letsdo/version.rb +2 -2
- data/lib/letsdo/watcher.rb +188 -0
- data/lib/letsdo.rb +20 -13
- metadata +32 -1
data/lib/letsdo/tui/session.rb
CHANGED
|
@@ -1,90 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'session/keys'
|
|
4
|
+
require_relative 'session/view'
|
|
5
|
+
|
|
3
6
|
module Letsdo
|
|
4
7
|
module Tui
|
|
5
|
-
# The TUI controller:
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# Threading model (the orchestrator loop stays on the calling thread,
|
|
9
|
-
# exactly as in plain mode — Letsdo::PiRunner's signal design keeps
|
|
10
|
-
# working unchanged):
|
|
11
|
-
# main thread — the injected block (Letsdo::AgentLoop#run with its
|
|
12
|
-
# own signal traps): provider queries, agent runs,
|
|
13
|
-
# streamer writes into the log, metrics events;
|
|
14
|
-
# input thread — the SOLE repainter: polls keys (tty-reader),
|
|
15
|
-
# drains the log snapshot, tracks the metrics
|
|
16
|
-
# snapshot and repaints on keys, data, 1s timer
|
|
17
|
-
# ticks and SIGWINCH. Never touches the loop.
|
|
18
|
-
#
|
|
19
|
-
# Interactive actions (footer lists them):
|
|
20
|
-
# ↑ / ↓ / PgUp / PgDn / Home / End — scroll; the view auto-follows
|
|
21
|
-
# the newest line while at the bottom (tail -f semantics);
|
|
22
|
-
# p — pause/resume: display freeze (PAUSED in the header, the log
|
|
23
|
-
# keeps buffering, the view stays); keys still repaint;
|
|
24
|
-
# r — immediate backlog re-query for the 'left' metric;
|
|
25
|
-
# q / Ctrl-C — quit: exactly the signal-stop unwinding — Letsdo::Stopped
|
|
26
|
-
# raised into the main thread terminates the pi child (PiRunner
|
|
27
|
-
# rescue), stops the loop (AgentLoop rescue) and restores the
|
|
28
|
-
# terminal from the ensure block; exit code 0.
|
|
29
|
-
#
|
|
30
|
-
# Signals: SIGWINCH sets a flag → the input thread re-queries the size
|
|
31
|
-
# and repaints (no corruption). SIGINT/SIGTERM keep working through the
|
|
32
|
-
# loop's own traps (main thread).
|
|
8
|
+
# The TUI controller: terminal lifecycle, background input thread, and
|
|
9
|
+
# the repaint loop around an orchestrator run.
|
|
33
10
|
class Session
|
|
34
|
-
|
|
11
|
+
include SessionKeys
|
|
12
|
+
include SessionView
|
|
13
|
+
|
|
35
14
|
REPAINT_INTERVAL = 1.0
|
|
36
|
-
# Poll sleep when the display is frozen or nothing happened (avoids a
|
|
37
|
-
# busy loop while still repainting at the timer interval).
|
|
38
15
|
IDLE_SLEEP = 0.01
|
|
39
|
-
# How long teardown waits for the input thread to finish its poll.
|
|
40
16
|
INPUT_JOIN_TIMEOUT = 2.0
|
|
41
17
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# @param input [Input] the key reader
|
|
48
|
-
# @param refresh [Proc, nil] callable → open task count (Integer) or
|
|
49
|
-
# nil (backlog unreadable); 'r' uses it for an immediate
|
|
50
|
-
# re-query
|
|
51
|
-
# @param wait_seconds [Numeric] retry interval for the waiting state
|
|
52
|
-
# @param clock [Proc] monotonic clock for tick timing (injectable)
|
|
53
|
-
def initialize(name:, handle:, log:, metrics:, terminal:, input:,
|
|
54
|
-
refresh: nil, wait_seconds: 10.0, clock: nil)
|
|
55
|
-
@name = name
|
|
56
|
-
@handle = handle
|
|
57
|
-
@log = log
|
|
58
|
-
@metrics = metrics
|
|
59
|
-
@terminal = terminal
|
|
60
|
-
@input = input
|
|
61
|
-
@refresh = refresh
|
|
62
|
-
@wait_seconds = wait_seconds
|
|
63
|
-
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
64
|
-
@offset = 0
|
|
65
|
-
@follow = true
|
|
66
|
-
@paused = false
|
|
67
|
-
@stop = false
|
|
68
|
-
@winch = false
|
|
69
|
-
@body_height = 1
|
|
70
|
-
@seen_version = 0
|
|
71
|
-
@input_thread = nil
|
|
18
|
+
def initialize(**opts)
|
|
19
|
+
assign_identity(opts)
|
|
20
|
+
assign_io(opts)
|
|
21
|
+
assign_controls(opts)
|
|
22
|
+
reset_view_state
|
|
72
23
|
end
|
|
73
24
|
|
|
74
|
-
# Runs the TUI around the given orchestrator work.
|
|
75
|
-
#
|
|
76
|
-
# The block runs on the calling thread. On quit (q/Ctrl-C) or a stop
|
|
77
|
-
# signal Letsdo::Stopped unwinds it; the terminal is restored from
|
|
78
|
-
# the ensure block on every exit path.
|
|
79
|
-
#
|
|
80
|
-
# @yield the orchestrator run (e.g. Letsdo::AgentLoop#run)
|
|
81
|
-
# @return [Integer] the block's result, or 0 when stopped
|
|
82
25
|
def run(&work)
|
|
83
26
|
install_winch_handler
|
|
84
27
|
@terminal.enter
|
|
85
|
-
|
|
86
|
-
result = work.call
|
|
87
|
-
result
|
|
28
|
+
run_work(&work)
|
|
88
29
|
rescue Letsdo::Stopped
|
|
89
30
|
0
|
|
90
31
|
ensure
|
|
@@ -93,124 +34,50 @@ module Letsdo
|
|
|
93
34
|
restore_winch_handler
|
|
94
35
|
end
|
|
95
36
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
# The input thread body: the only place that writes to the terminal.
|
|
99
|
-
def input_loop
|
|
37
|
+
def run_work(&work)
|
|
100
38
|
with_raw_input do
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
loop do
|
|
104
|
-
break if @stop
|
|
105
|
-
|
|
106
|
-
key = @input.next_key
|
|
107
|
-
handled = key ? handle_key(key) : false
|
|
108
|
-
winch = take_winch
|
|
109
|
-
handled = true if winch
|
|
110
|
-
now = @clock.call
|
|
111
|
-
tick = now - @last_repaint >= REPAINT_INTERVAL
|
|
112
|
-
new_data = @log.version != @seen_version
|
|
113
|
-
if handled || (!@paused && (tick || new_data))
|
|
114
|
-
repaint
|
|
115
|
-
@last_repaint = @clock.call
|
|
116
|
-
else
|
|
117
|
-
sleep(IDLE_SLEEP)
|
|
118
|
-
end
|
|
119
|
-
end
|
|
39
|
+
start_input_thread
|
|
40
|
+
work.call
|
|
120
41
|
end
|
|
121
|
-
rescue StandardError
|
|
122
|
-
# The input thread must never die loudly into the middle of the
|
|
123
|
-
# screen, and a quiet exit still leaves the loop running (teardown
|
|
124
|
-
# joins it). Terminal state is restored by the ensure blocks.
|
|
125
|
-
nil
|
|
126
42
|
end
|
|
127
43
|
|
|
128
|
-
|
|
129
|
-
case key
|
|
130
|
-
when :up, :page_up
|
|
131
|
-
@offset -= key == :page_up ? page_step : 1
|
|
132
|
-
@follow = false
|
|
133
|
-
true
|
|
134
|
-
when :down, :page_down
|
|
135
|
-
@offset += key == :page_down ? page_step : 1
|
|
136
|
-
true # repaint decides re-sticking at the bottom
|
|
137
|
-
when :home
|
|
138
|
-
@offset = 0
|
|
139
|
-
@follow = false
|
|
140
|
-
true
|
|
141
|
-
when :end
|
|
142
|
-
@follow = true
|
|
143
|
-
true
|
|
144
|
-
when :p
|
|
145
|
-
@paused = !@paused
|
|
146
|
-
true
|
|
147
|
-
when :r
|
|
148
|
-
refresh
|
|
149
|
-
true
|
|
150
|
-
when :q, :ctrl_c
|
|
151
|
-
quit
|
|
152
|
-
false # leaving the session; no repaint needed
|
|
153
|
-
else
|
|
154
|
-
false
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Immediate backlog re-query: the provider is re-run on this thread
|
|
159
|
-
# (a subprocess call, thread-safe) and the count reaches the header
|
|
160
|
-
# snapshot.
|
|
161
|
-
def refresh
|
|
162
|
-
return unless @refresh
|
|
163
|
-
|
|
164
|
-
@metrics.provider_result(@refresh.call)
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# Quit = the same unwinding as a stop signal: Letsdo::Stopped raised
|
|
168
|
-
# into the main thread (inside the orchestrator work) terminates the
|
|
169
|
-
# pi child and stops the loop; the ensure block restores the
|
|
170
|
-
# terminal. No-op after teardown already started.
|
|
171
|
-
def quit
|
|
172
|
-
return if @stop
|
|
44
|
+
private
|
|
173
45
|
|
|
174
|
-
|
|
46
|
+
def assign_identity(opts)
|
|
47
|
+
@name = opts.fetch(:name)
|
|
48
|
+
@handle = opts.fetch(:handle)
|
|
49
|
+
@log = opts.fetch(:log)
|
|
50
|
+
@metrics = opts.fetch(:metrics)
|
|
175
51
|
end
|
|
176
52
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
@
|
|
181
|
-
|
|
182
|
-
@
|
|
183
|
-
max_offset = Renderer.max_offset(lines, @body_height)
|
|
184
|
-
if @paused
|
|
185
|
-
@offset = [[@offset, max_offset].min, 0].max
|
|
186
|
-
elsif @follow
|
|
187
|
-
@offset = max_offset
|
|
188
|
-
else
|
|
189
|
-
@offset = [[@offset, max_offset].min, 0].max
|
|
190
|
-
@follow = true if @offset == max_offset
|
|
191
|
-
end
|
|
192
|
-
frame = Renderer.render(
|
|
193
|
-
metrics: @metrics.snapshot, lines: lines, width: width, height: height,
|
|
194
|
-
offset: @offset, follow: @follow, paused: @paused,
|
|
195
|
-
wait_seconds: @wait_seconds
|
|
196
|
-
)
|
|
197
|
-
@terminal.render(frame)
|
|
53
|
+
def assign_io(opts)
|
|
54
|
+
@terminal = opts.fetch(:terminal)
|
|
55
|
+
@input = opts.fetch(:input)
|
|
56
|
+
@refresh = opts[:refresh]
|
|
57
|
+
@wait_seconds = opts.fetch(:wait_seconds, 10.0)
|
|
58
|
+
@clock = opts[:clock] || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
198
59
|
end
|
|
199
60
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
@
|
|
61
|
+
def assign_controls(opts)
|
|
62
|
+
@pause_gate = opts[:pause_gate]
|
|
63
|
+
@runner = opts[:runner]
|
|
203
64
|
end
|
|
204
65
|
|
|
205
|
-
def
|
|
206
|
-
|
|
66
|
+
def reset_view_state
|
|
67
|
+
@offset = 0
|
|
68
|
+
@follow = true
|
|
69
|
+
@paused = false
|
|
70
|
+
@stop = false
|
|
207
71
|
@winch = false
|
|
208
|
-
|
|
72
|
+
@body_height = 1
|
|
73
|
+
@seen_version = 0
|
|
74
|
+
@input_thread = nil
|
|
209
75
|
end
|
|
210
76
|
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
#
|
|
77
|
+
# Enters raw mode on the keyboard for the duration of the block. Held
|
|
78
|
+
# by the main thread (not the killable input thread) so the terminal
|
|
79
|
+
# state is restored on every quit path — a killed input thread would
|
|
80
|
+
# leave the shared tty in raw mode and break the calling shell (TASK-83).
|
|
214
81
|
def with_raw_input(&block)
|
|
215
82
|
if @input.stdin.tty? && @input.stdin.respond_to?(:raw)
|
|
216
83
|
@input.stdin.raw(&block)
|
|
@@ -220,13 +87,13 @@ module Letsdo
|
|
|
220
87
|
end
|
|
221
88
|
|
|
222
89
|
def install_winch_handler
|
|
223
|
-
Signal.trap(
|
|
90
|
+
Signal.trap('SIGWINCH') { @winch = true }
|
|
224
91
|
rescue ArgumentError
|
|
225
|
-
nil
|
|
92
|
+
nil
|
|
226
93
|
end
|
|
227
94
|
|
|
228
95
|
def restore_winch_handler
|
|
229
|
-
Signal.trap(
|
|
96
|
+
Signal.trap('SIGWINCH', 'DEFAULT')
|
|
230
97
|
rescue ArgumentError
|
|
231
98
|
nil
|
|
232
99
|
end
|
|
@@ -250,4 +117,4 @@ module Letsdo
|
|
|
250
117
|
end
|
|
251
118
|
end
|
|
252
119
|
end
|
|
253
|
-
end
|
|
120
|
+
end
|
data/lib/letsdo/tui/terminal.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "tty-cursor"
|
|
4
|
-
|
|
5
3
|
module Letsdo
|
|
6
4
|
module Tui
|
|
7
5
|
# Thin wrapper over the terminal: alternate screen, cursor handling and
|
|
@@ -24,28 +22,40 @@ module Letsdo
|
|
|
24
22
|
# @param size_provider [Proc] callable → [height, width]; defaults to
|
|
25
23
|
# TTY::Screen.size; injected in tests for deterministic size
|
|
26
24
|
def initialize(stream:, size_provider: nil)
|
|
27
|
-
require
|
|
25
|
+
require 'tty-screen' unless defined?(TTY::Screen)
|
|
28
26
|
|
|
29
27
|
@stream = stream
|
|
30
28
|
@size_provider = size_provider ||
|
|
31
|
-
|
|
29
|
+
lambda {
|
|
30
|
+
size = TTY::Screen.size
|
|
31
|
+
[size[0] || 24, size[1] || 80]
|
|
32
|
+
}
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# Enters the alternate screen and hides the cursor.
|
|
35
36
|
def enter
|
|
36
|
-
write(ENTER_ALT_SCREEN +
|
|
37
|
+
write(ENTER_ALT_SCREEN + cursor.hide)
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
# Shows the cursor and leaves the alternate screen.
|
|
40
41
|
def leave
|
|
41
|
-
write(
|
|
42
|
+
write(cursor.show + LEAVE_ALT_SCREEN)
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
# Repaints the whole frame: cursor to the top left, then the frame.
|
|
45
46
|
#
|
|
47
|
+
# Row separators are written as CRLF (\r\n), never bare LF. The TUI
|
|
48
|
+
# input thread holds stdin in io-console raw mode for the whole
|
|
49
|
+
# session, and raw mode clears OPOST on the shared tty — without it a
|
|
50
|
+
# bare \n no longer implies a carriage return, so every row after the
|
|
51
|
+
# first would start at the previous row's end column and the frame
|
|
52
|
+
# would wrap/scroll into the "blank activity pane" scramble (TASK-82).
|
|
53
|
+
# Explicit CRLF renders correctly both in raw mode and on terminals
|
|
54
|
+
# whose ONLCR already expands \n (a doubled CR is harmless).
|
|
55
|
+
#
|
|
46
56
|
# @param frame [String] the rendered screen (see Letsdo::Tui::Renderer)
|
|
47
57
|
def render(frame)
|
|
48
|
-
write(HOME + frame)
|
|
58
|
+
write(HOME + frame.gsub("\n", "\r\n"))
|
|
49
59
|
end
|
|
50
60
|
|
|
51
61
|
# The current terminal size.
|
|
@@ -58,10 +68,18 @@ module Letsdo
|
|
|
58
68
|
|
|
59
69
|
private
|
|
60
70
|
|
|
71
|
+
# Loaded lazily (like tty-screen/tty-reader) so requiring letsdo on a
|
|
72
|
+
# clean Ruby loads no tty-* gems — only the interactive TTY path needs
|
|
73
|
+
# them (TASK-78).
|
|
74
|
+
def cursor
|
|
75
|
+
require 'tty-cursor' unless defined?(TTY::Cursor)
|
|
76
|
+
TTY::Cursor
|
|
77
|
+
end
|
|
78
|
+
|
|
61
79
|
def write(text)
|
|
62
80
|
@stream.write(text)
|
|
63
81
|
@stream.flush
|
|
64
82
|
end
|
|
65
83
|
end
|
|
66
84
|
end
|
|
67
|
-
end
|
|
85
|
+
end
|
data/lib/letsdo/tui.rb
CHANGED
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
# Letsdo::Tui::Input - tty-reader key decoding (injectable)
|
|
17
17
|
# Letsdo::Tui::Session - controller: terminal lifecycle + input thread
|
|
18
18
|
|
|
19
|
-
require_relative
|
|
20
|
-
require_relative
|
|
21
|
-
require_relative
|
|
22
|
-
require_relative
|
|
23
|
-
require_relative
|
|
24
|
-
require_relative
|
|
19
|
+
require_relative 'tui/log_buffer'
|
|
20
|
+
require_relative 'tui/metrics'
|
|
21
|
+
require_relative 'tui/renderer'
|
|
22
|
+
require_relative 'tui/terminal'
|
|
23
|
+
require_relative 'tui/input'
|
|
24
|
+
require_relative 'tui/session'
|
data/lib/letsdo/version.rb
CHANGED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fiddle/import'
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
# OS file-change watcher for the backlog folder.
|
|
7
|
+
#
|
|
8
|
+
# On Linux it subscribes to inotify (via Fiddle, no external gem) and wakes
|
|
9
|
+
# as soon as a backlog entry changes; everywhere else it degrades to the
|
|
10
|
+
# same periodic polling a plain sleeper would do. Either way the wait is
|
|
11
|
+
# interruptible — a signal trap raising Letsdo::Stopped breaks the
|
|
12
|
+
# underlying IO.select — and an internal self-pipe lets #wake interrupt a
|
|
13
|
+
# blocked #wait from another thread.
|
|
14
|
+
class Watcher
|
|
15
|
+
# inotify event masks we subscribe to (Linux <sys/inotify.h>).
|
|
16
|
+
IN_ACCESS = 0x00000001
|
|
17
|
+
IN_MODIFY = 0x00000002
|
|
18
|
+
IN_ATTRIB = 0x00000004
|
|
19
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
20
|
+
IN_CLOSE_NOWRITE = 0x00000010
|
|
21
|
+
IN_OPEN = 0x00000020
|
|
22
|
+
IN_MOVED_FROM = 0x00000040
|
|
23
|
+
IN_MOVED_TO = 0x00000080
|
|
24
|
+
IN_CREATE = 0x00000100
|
|
25
|
+
IN_DELETE = 0x00000200
|
|
26
|
+
IN_DELETE_SELF = 0x00000400
|
|
27
|
+
IN_MOVE_SELF = 0x00000800
|
|
28
|
+
|
|
29
|
+
RELEVANT_MASK = IN_CREATE | IN_MODIFY | IN_CLOSE_WRITE | IN_DELETE |
|
|
30
|
+
IN_MOVED_FROM | IN_MOVED_TO | IN_DELETE_SELF | IN_MOVE_SELF
|
|
31
|
+
|
|
32
|
+
EXCLUDED_DIR = '.locks'
|
|
33
|
+
|
|
34
|
+
# @param path [String] the watched folder (its direct entries)
|
|
35
|
+
# @param poll_seconds [Float] fallback poll interval / select timeout
|
|
36
|
+
def initialize(path:, poll_seconds: 10.0)
|
|
37
|
+
@poll_seconds = poll_seconds
|
|
38
|
+
@wake_r, @wake_w = IO.pipe
|
|
39
|
+
@backend = build_backend(path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Blocks until a relevant backlog change, an explicit #wake, or the poll
|
|
43
|
+
# timeout. Irrelevant events (e.g. .locks churn) keep the wait going for
|
|
44
|
+
# the remainder of the timeout.
|
|
45
|
+
#
|
|
46
|
+
# @return [Symbol] :change, :wake, or :timeout
|
|
47
|
+
def wait(timeout = @poll_seconds)
|
|
48
|
+
deadline = monotonic + timeout
|
|
49
|
+
loop do
|
|
50
|
+
result = wait_once(deadline)
|
|
51
|
+
return result if result
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Interrupts a blocked #wait from another thread.
|
|
56
|
+
def wake
|
|
57
|
+
@wake_w.write_nonblock('.')
|
|
58
|
+
rescue IO::WaitWritable, Errno::EPIPE
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def close
|
|
63
|
+
@backend&.close
|
|
64
|
+
close_io(@wake_w)
|
|
65
|
+
close_io(@wake_r)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def wait_once(deadline)
|
|
71
|
+
remaining = deadline - monotonic
|
|
72
|
+
return :timeout if remaining <= 0
|
|
73
|
+
|
|
74
|
+
ready = IO.select(read_fds, nil, nil, remaining)
|
|
75
|
+
return :timeout if ready.nil?
|
|
76
|
+
return :wake if wake_ready?(ready)
|
|
77
|
+
|
|
78
|
+
@backend.relevant_event? ? :change : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def read_fds
|
|
82
|
+
fds = [@wake_r]
|
|
83
|
+
fds << @backend.io if @backend
|
|
84
|
+
fds
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def wake_ready?(ready)
|
|
88
|
+
return false unless ready.first.include?(@wake_r)
|
|
89
|
+
|
|
90
|
+
drain_wake
|
|
91
|
+
true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def drain_wake
|
|
95
|
+
@wake_r.read_nonblock(4096)
|
|
96
|
+
rescue IO::WaitReadable, EOFError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_backend(path)
|
|
101
|
+
InotifyBackend.new(path, RELEVANT_MASK)
|
|
102
|
+
rescue InotifyBackend::Unavailable, Fiddle::DLError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def monotonic
|
|
107
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def close_io(io)
|
|
111
|
+
io.close unless io.nil? || io.closed?
|
|
112
|
+
rescue IOError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Linux inotify backend. Construction raises InotifyBackend::Unavailable
|
|
117
|
+
# when inotify is missing or the watch cannot be set up, so the watcher
|
|
118
|
+
# falls back to polling through its self-pipe timeout.
|
|
119
|
+
class InotifyBackend
|
|
120
|
+
# libc inotify bindings via Fiddle (no external gem).
|
|
121
|
+
module Lib
|
|
122
|
+
extend Fiddle::Importer
|
|
123
|
+
dlload Fiddle.dlopen(nil)
|
|
124
|
+
extern 'int inotify_init1(int)'
|
|
125
|
+
extern 'int inotify_add_watch(int, const char*, uint32_t)'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Signals that inotify is unavailable and the watcher should fall back.
|
|
129
|
+
class Unavailable < StandardError; end
|
|
130
|
+
|
|
131
|
+
IN_CLOEXEC = 0x00080000
|
|
132
|
+
EVENT_HEADER_SIZE = 16
|
|
133
|
+
|
|
134
|
+
attr_reader :io
|
|
135
|
+
|
|
136
|
+
def initialize(path, mask)
|
|
137
|
+
fd = Lib.inotify_init1(IN_CLOEXEC)
|
|
138
|
+
guard(fd)
|
|
139
|
+
@io = IO.new(fd, 'r', autoclose: true)
|
|
140
|
+
guard(Lib.inotify_add_watch(fd, path, mask))
|
|
141
|
+
rescue Unavailable, Fiddle::DLError
|
|
142
|
+
@io&.close
|
|
143
|
+
raise Unavailable
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Drains pending events and reports whether any relevant (non-.locks)
|
|
147
|
+
# entry changed.
|
|
148
|
+
def relevant_event?
|
|
149
|
+
buffer = @io.read_nonblock(8192)
|
|
150
|
+
parse_relevant(buffer)
|
|
151
|
+
rescue IO::WaitReadable, EOFError
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def close
|
|
156
|
+
@io&.close
|
|
157
|
+
rescue IOError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def guard(value)
|
|
164
|
+
raise Unavailable if value.negative?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse_relevant(buffer)
|
|
168
|
+
offset = 0
|
|
169
|
+
relevant = false
|
|
170
|
+
while offset + EVENT_HEADER_SIZE <= buffer.bytesize
|
|
171
|
+
_wd, mask, _cookie, len = buffer.byteslice(offset, EVENT_HEADER_SIZE).unpack('L4')
|
|
172
|
+
name = buffer.byteslice(offset + EVENT_HEADER_SIZE, len).to_s.sub(/\0.*/m, '')
|
|
173
|
+
relevant ||= relevant_mask?(mask) && !excluded?(name)
|
|
174
|
+
offset += EVENT_HEADER_SIZE + len
|
|
175
|
+
end
|
|
176
|
+
relevant
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def relevant_mask?(mask)
|
|
180
|
+
(mask & Watcher::RELEVANT_MASK) != 0
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def excluded?(name)
|
|
184
|
+
name == Watcher::EXCLUDED_DIR || name.start_with?("#{Watcher::EXCLUDED_DIR}/")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/lib/letsdo.rb
CHANGED
|
@@ -3,28 +3,35 @@
|
|
|
3
3
|
# The Letsdo package — a local agent worker for Backlog.md/markdown tasks.
|
|
4
4
|
#
|
|
5
5
|
# OOP structure:
|
|
6
|
-
# Letsdo::Errors - error hierarchy (
|
|
6
|
+
# Letsdo::Errors - error hierarchy (Letsdo::Error and others)
|
|
7
7
|
# Letsdo::PromptStore - access to agents/*.md prompts
|
|
8
|
+
# Letsdo::DefaultPrompt - the built-in default prompt (fallback run, --init template)
|
|
8
9
|
# Letsdo::OutputStreamer - where and how agent text and service lines are printed
|
|
9
10
|
# Letsdo::PiRunner - running pi --mode json and parsing the event stream
|
|
10
|
-
# Letsdo::Agent - a single agent run: prompt from agents/ + pi
|
|
11
|
+
# Letsdo::Agent - a single agent run: prompt from agents/ or default + pi
|
|
12
|
+
# Letsdo::Capture - interruption-safe child stdout/stderr capture
|
|
11
13
|
# Letsdo::BacklogTasks - open tasks from the backlog CLI (the task provider)
|
|
12
14
|
# Letsdo::Loop - generic orchestrator: tasks → runs → waiting
|
|
13
15
|
# Letsdo::AgentLoop - letsdo wiring: provider + agent + signals + messages
|
|
16
|
+
# Letsdo::Control - agent-control primitives (PauseGate)
|
|
14
17
|
# Letsdo::Tui - the interactive TUI (LogBuffer, Metrics,
|
|
15
18
|
# Renderer, Terminal, Input, Session)
|
|
16
19
|
# Letsdo::CLI - command-line arguments, usage, exit code
|
|
17
20
|
#
|
|
18
21
|
# Entry point — bin/letsdo (a thin wrapper over Letsdo::CLI).
|
|
19
22
|
|
|
20
|
-
require_relative
|
|
21
|
-
require_relative
|
|
22
|
-
require_relative
|
|
23
|
-
require_relative
|
|
24
|
-
require_relative
|
|
25
|
-
require_relative
|
|
26
|
-
require_relative
|
|
27
|
-
require_relative
|
|
28
|
-
require_relative
|
|
29
|
-
require_relative
|
|
30
|
-
require_relative
|
|
23
|
+
require_relative 'letsdo/version'
|
|
24
|
+
require_relative 'letsdo/errors'
|
|
25
|
+
require_relative 'letsdo/prompt_store'
|
|
26
|
+
require_relative 'letsdo/default_prompt'
|
|
27
|
+
require_relative 'letsdo/output_streamer'
|
|
28
|
+
require_relative 'letsdo/pi_runner'
|
|
29
|
+
require_relative 'letsdo/agent'
|
|
30
|
+
require_relative 'letsdo/capture'
|
|
31
|
+
require_relative 'letsdo/backlog_tasks'
|
|
32
|
+
require_relative 'letsdo/loop'
|
|
33
|
+
require_relative 'letsdo/watcher'
|
|
34
|
+
require_relative 'letsdo/agent_loop'
|
|
35
|
+
require_relative 'letsdo/control'
|
|
36
|
+
require_relative 'letsdo/tui'
|
|
37
|
+
require_relative 'letsdo/cli'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: letsdo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergei O. Udalov
|
|
@@ -65,6 +65,20 @@ dependencies:
|
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '2.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 1.77.0
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: 1.77.0
|
|
68
82
|
description: 'letsdo — a local agent worker for Backlog.md/markdown tasks. OOP structure:
|
|
69
83
|
Letsdo::PromptStore (agents/), Letsdo::OutputStreamer and Letsdo::PiRunner (pi --mode
|
|
70
84
|
json), Letsdo::Agent (one run), Letsdo::Loop (orchestrator loop), Letsdo::CLI. Minitest
|
|
@@ -82,12 +96,21 @@ files:
|
|
|
82
96
|
- lib/letsdo.rb
|
|
83
97
|
- lib/letsdo/agent.rb
|
|
84
98
|
- lib/letsdo/agent_loop.rb
|
|
99
|
+
- lib/letsdo/agent_loop/tasks.rb
|
|
85
100
|
- lib/letsdo/backlog_tasks.rb
|
|
101
|
+
- lib/letsdo/capture.rb
|
|
86
102
|
- lib/letsdo/cli.rb
|
|
103
|
+
- lib/letsdo/cli/init.rb
|
|
104
|
+
- lib/letsdo/cli/launch.rb
|
|
105
|
+
- lib/letsdo/control.rb
|
|
106
|
+
- lib/letsdo/default_prompt.rb
|
|
87
107
|
- lib/letsdo/errors.rb
|
|
88
108
|
- lib/letsdo/loop.rb
|
|
89
109
|
- lib/letsdo/output_streamer.rb
|
|
110
|
+
- lib/letsdo/output_streamer/arg_summary.rb
|
|
90
111
|
- lib/letsdo/pi_runner.rb
|
|
112
|
+
- lib/letsdo/pi_runner/events.rb
|
|
113
|
+
- lib/letsdo/pi_runner/process.rb
|
|
91
114
|
- lib/letsdo/prompt_store.rb
|
|
92
115
|
- lib/letsdo/tui.rb
|
|
93
116
|
- lib/letsdo/tui/input.rb
|
|
@@ -95,12 +118,20 @@ files:
|
|
|
95
118
|
- lib/letsdo/tui/metrics.rb
|
|
96
119
|
- lib/letsdo/tui/renderer.rb
|
|
97
120
|
- lib/letsdo/tui/session.rb
|
|
121
|
+
- lib/letsdo/tui/session/keys.rb
|
|
122
|
+
- lib/letsdo/tui/session/view.rb
|
|
98
123
|
- lib/letsdo/tui/terminal.rb
|
|
99
124
|
- lib/letsdo/version.rb
|
|
125
|
+
- lib/letsdo/watcher.rb
|
|
126
|
+
homepage: https://github.com/sergio-fry/letsdo
|
|
100
127
|
licenses:
|
|
101
128
|
- MIT
|
|
102
129
|
metadata:
|
|
130
|
+
homepage_uri: https://github.com/sergio-fry/letsdo
|
|
131
|
+
source_code_uri: https://github.com/sergio-fry/letsdo
|
|
132
|
+
changelog_uri: https://github.com/sergio-fry/letsdo/blob/main/CHANGELOG.md
|
|
103
133
|
rubygems_mfa_required: 'true'
|
|
134
|
+
allowed_push_host: https://rubygems.org
|
|
104
135
|
rdoc_options: []
|
|
105
136
|
require_paths:
|
|
106
137
|
- lib
|