letsdo 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -2
  3. data/README.md +150 -18
  4. data/bin/letsdo +8 -1
  5. data/docs/config.md +176 -0
  6. data/docs/prompts.md +210 -0
  7. data/docs/task-selection.md +244 -0
  8. data/docs/usage.md +296 -0
  9. data/letsdo.gemspec +3 -1
  10. data/lib/letsdo/agent.rb +30 -17
  11. data/lib/letsdo/agent_identity.rb +46 -0
  12. data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
  13. data/lib/letsdo/agent_loop/tasks.rb +88 -13
  14. data/lib/letsdo/agent_loop.rb +39 -3
  15. data/lib/letsdo/backends/backend.rb +137 -0
  16. data/lib/letsdo/backends/pi/events.rb +77 -0
  17. data/lib/letsdo/backends/pi.rb +88 -0
  18. data/lib/letsdo/cli/builder.rb +47 -95
  19. data/lib/letsdo/cli/builder_assembly.rb +154 -0
  20. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  21. data/lib/letsdo/cli/doctor.rb +16 -0
  22. data/lib/letsdo/cli.rb +15 -1
  23. data/lib/letsdo/config.rb +89 -8
  24. data/lib/letsdo/control/reader.rb +131 -0
  25. data/lib/letsdo/control.rb +6 -3
  26. data/lib/letsdo/doctor/checks.rb +144 -0
  27. data/lib/letsdo/doctor.rb +42 -0
  28. data/lib/letsdo/duration.rb +23 -0
  29. data/lib/letsdo/errors.rb +7 -0
  30. data/lib/letsdo/metrics/fanout.rb +47 -0
  31. data/lib/letsdo/prompt_store.rb +48 -1
  32. data/lib/letsdo/providers/backlog.rb +180 -0
  33. data/lib/letsdo/providers/task.rb +48 -0
  34. data/lib/letsdo/retry_policy.rb +98 -0
  35. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  36. data/lib/letsdo/session_recorder.rb +188 -0
  37. data/lib/letsdo/task_time_writeback.rb +106 -0
  38. data/lib/letsdo/tui/metrics.rb +10 -3
  39. data/lib/letsdo/tui/renderer.rb +9 -1
  40. data/lib/letsdo/tui/session/terminal.rb +47 -0
  41. data/lib/letsdo/tui/session/view.rb +10 -2
  42. data/lib/letsdo/tui/session.rb +27 -22
  43. data/lib/letsdo/tui/window_title.rb +133 -0
  44. data/lib/letsdo/tui.rb +4 -0
  45. data/lib/letsdo/version.rb +1 -1
  46. data/lib/letsdo.rb +16 -5
  47. metadata +27 -6
  48. data/lib/letsdo/backlog_tasks.rb +0 -59
  49. data/lib/letsdo/pi_runner/events.rb +0 -75
  50. data/lib/letsdo/pi_runner/process.rb +0 -68
  51. data/lib/letsdo/pi_runner.rb +0 -101
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ class CLI
5
+ # Provider and backend registries used by Letsdo::CLI::Builder: name ->
6
+ # factory maps, injectable via the constructor for tests. Kept separate so
7
+ # the assembly module stays within the module-length limit.
8
+ module BuilderRegistries
9
+ # Provider registry: maps LETSDO_PROVIDER names to factories that build
10
+ # a provider from handle, command, cwd, and env. Inject a custom
11
+ # registry via the constructor for tests.
12
+ PROVIDERS = {
13
+ 'backlog' => lambda do |handle:, command:, cwd:, env:|
14
+ Providers::Backlog.new(handle: handle, command: command, cwd: cwd, env: env)
15
+ end
16
+ }.freeze
17
+
18
+ # Backend registry: maps LETSDO_BACKEND names to factory builders that
19
+ # take the Letsdo::Config and return a backend_factory (callable with
20
+ # prompt:, streamer:, model:). The pi entry captures the pi command and
21
+ # flags from Config (LETSDO_PI_COMMAND / LETSDO_PI_FLAGS with the
22
+ # AGENT_PI_FLAGS fallback), keeping pi vocabulary out of the business
23
+ # layer. Inject a custom registry via the constructor for tests.
24
+ BACKENDS = {
25
+ 'pi' => lambda do |config:|
26
+ command = config.pi_command
27
+ flags = config.pi_flags
28
+ lambda { |prompt:, streamer:, model: nil|
29
+ Letsdo::Backends::Pi.new(prompt: prompt, streamer: streamer,
30
+ flags: flags, command: command,
31
+ model: model, config: config)
32
+ }
33
+ end
34
+ }.freeze
35
+ end
36
+
37
+ # Registry-driven component wiring for Letsdo::CLI::Builder: the run
38
+ # entry point, agent/provider/loop assembly and the plain-mode run path.
39
+ # Extracted into its own module so Builder stays within the class-length
40
+ # limit (same reason as the BuilderTui split).
41
+ module BuilderAssembly
42
+ include BuilderRegistries
43
+
44
+ def initialize(env:, stdout:, stderr:, **rest)
45
+ @env = env
46
+ @stdout = stdout
47
+ @stderr = stderr
48
+ @stdin = rest[:stdin] || $stdin
49
+ @sleeper = rest[:sleeper]
50
+ @config = Config.new(env: env)
51
+ @root = @config.root
52
+ @provider_registry = rest[:provider_registry] || PROVIDERS
53
+ @backend_registry = rest[:backend_registry] || BACKENDS
54
+ end
55
+
56
+ # Runs <name> in plain or TUI mode; returns the process exit code.
57
+ def run(name)
58
+ return 1 unless resolve_provider!
59
+ return 1 unless resolve_backend!
60
+
61
+ store = PromptStore.new(root: @root)
62
+ announce_default_prompt(name, store) if store.read(name).nil?
63
+ recorder = build_recorder(name)
64
+ tui? ? run_tui(name, recorder) : run_plain(name, recorder)
65
+ end
66
+
67
+ private
68
+
69
+ def agent_for(name, streamer)
70
+ Agent.new(name: name, root: @root, handle: assignee_handle(name),
71
+ backend_factory: @selected_backend_factory,
72
+ streamer: streamer)
73
+ end
74
+
75
+ # Resolves LETSDO_BACKEND through the backend registry once per run,
76
+ # so both wiring paths (plain and TUI) build the Agent's
77
+ # backend_factory through the same registry entry. An unknown backend
78
+ # name fails fast instead of silently falling back.
79
+ def resolve_backend!
80
+ builder = @backend_registry[@config.backend]
81
+ if builder
82
+ @selected_backend_factory = builder.call(config: @config)
83
+ return true
84
+ end
85
+
86
+ @stderr.puts("letsdo: unknown AI backend: #{@config.backend}")
87
+ false
88
+ end
89
+
90
+ def resolve_provider!
91
+ @selected_provider_factory = @provider_registry[@config.provider]
92
+ return true if @selected_provider_factory
93
+
94
+ @stderr.puts("letsdo: unknown task provider: #{@config.provider}")
95
+ false
96
+ end
97
+
98
+ def provider_for(handle)
99
+ @selected_provider_factory.call(
100
+ handle: handle,
101
+ command: backlog_command,
102
+ cwd: @root,
103
+ env: ENV.to_h.merge(@env)
104
+ )
105
+ end
106
+
107
+ def agent_loop(name, streamer, **opts)
108
+ handle = opts[:handle] || assignee_handle(name)
109
+ agent = opts[:agent] || agent_for(name, streamer)
110
+ provider = opts[:provider] || provider_for(handle)
111
+ # The provider object itself (not a wrapping lambda) reaches the loop
112
+ # so the once-per-run assignee mismatch hint can read its
113
+ # #assignee_variants (TASK-96).
114
+ AgentLoop.new(name: name, handle: handle, agent: agent,
115
+ task_provider: provider,
116
+ wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
117
+ metrics: opts[:metrics], pause_gate: opts[:pause_gate],
118
+ watcher: backlog_watcher, **retry_options)
119
+ end
120
+
121
+ def retry_options
122
+ { retry_base: @config.retry_base, retry_cap: @config.retry_cap, max_retries: @config.max_retries }
123
+ end
124
+
125
+ def backlog_watcher
126
+ Watcher.new(path: File.join(@root, 'backlog'), poll_seconds: wait_seconds)
127
+ end
128
+
129
+ # One-time fallback notification (before the first loop message): names
130
+ # the exact path checked and the placement hint. The agent still starts
131
+ # — Letsdo::Agent falls back to the built-in default prompt itself.
132
+ def announce_default_prompt(name, store)
133
+ @stderr.puts("letsdo: no prompt for #{name} at #{store.agent_path(name)}")
134
+ @stderr.puts("letsdo: using the built-in default prompt (create a prompt file with 'letsdo #{name} --init')")
135
+ end
136
+
137
+ def tui?
138
+ @stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
139
+ end
140
+
141
+ def assignee_handle(name)
142
+ @config.assignee_handle(name)
143
+ end
144
+
145
+ def backlog_command
146
+ @config.backlog_command
147
+ end
148
+
149
+ def wait_seconds
150
+ @config.wait_seconds
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ class CLI
5
+ # Session recorder and metrics-file wiring for Letsdo::CLI::Builder.
6
+ # Kept in its own module so BuilderAssembly stays within the module-length
7
+ # limit.
8
+ module BuilderMetrics
9
+ # Returns the IO for the LETSDO_METRICS_FILE, or nil if unset or
10
+ # unwritable. Emits a warning to stderr on invalid path.
11
+ def metrics_io
12
+ path = @env['LETSDO_METRICS_FILE']
13
+ return nil if path.nil? || path.empty?
14
+
15
+ File.open(path, 'a', encoding: 'UTF-8')
16
+ rescue SystemCallError, IOError => e
17
+ @stderr.puts("letsdo: cannot open metrics file #{path}: #{e.message}")
18
+ nil
19
+ end
20
+
21
+ private
22
+
23
+ def build_recorder(name)
24
+ Letsdo::SessionRecorder.new(name: name, handle: assignee_handle(name),
25
+ metrics_io: metrics_io)
26
+ end
27
+
28
+ def run_plain(name, recorder)
29
+ streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
30
+ agent = agent_for(name, streamer)
31
+ pause_gate = Control::PauseGate.new
32
+ reader = plain_control_reader(agent, pause_gate)
33
+ agent_loop(name, streamer, agent: agent, stderr: @stderr, metrics: recorder,
34
+ pause_gate: pause_gate).run
35
+ ensure
36
+ reader&.stop
37
+ finish_session(name, recorder)
38
+ end
39
+
40
+ # Plain-mode control (TASK-75): with a terminal stdin the reader maps
41
+ # p/q to the same actions as the TUI keys — the shared PauseGate plus
42
+ # the current backend (nil between runs is a no-op). Reader#start is a
43
+ # no-op when stdin is not a terminal (pipes, CI), so stopping stays
44
+ # signal-only there. The reader writes nothing, so the plain stream
45
+ # stays byte-identical.
46
+ def plain_control_reader(agent, pause_gate)
47
+ Control::Reader.new(input: @stdin, pause_gate: pause_gate,
48
+ runner: -> { agent.backend }).tap(&:start)
49
+ end
50
+
51
+ # One shared stop path for plain and TUI mode (TASK-69/TASK-70): close
52
+ # the recorder, optionally write per-task elapsed back into the task
53
+ # records, then print the summary. Write-back failures are counted and
54
+ # reported, never raised.
55
+ def finish_session(name, recorder)
56
+ recorder.session_stop
57
+ not_written = task_time_comments(name, recorder)
58
+ @stderr.puts(recorder.summary_line)
59
+ @stderr.puts(comment_failure_line(not_written)) if not_written.positive?
60
+ end
61
+
62
+ # Opt-in (LETSDO_TASK_TIME_COMMENT=1): re-query the provider once at
63
+ # stop and comment the elapsed time on runs whose task is gone. Off by
64
+ # default — no subprocess, no task file mutation.
65
+ def task_time_comments(name, recorder)
66
+ return 0 unless @config.task_time_comment?
67
+
68
+ writeback = TaskTimeWriteback.new(command: backlog_command, cwd: @root,
69
+ env: ENV.to_h.merge(@env), stderr: @stderr)
70
+ writeback.call(recorder.summary.runs, final_open_tasks(name))
71
+ end
72
+
73
+ def final_open_tasks(name)
74
+ provider_for(assignee_handle(name)).call
75
+ end
76
+
77
+ def comment_failure_line(count)
78
+ "letsdo: #{count} #{count == 1 ? 'comment' : 'comments'} not written"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ class CLI
5
+ # `letsdo doctor` dispatch (TASK-71): builds the environment self-check
6
+ # from the same Letsdo::Config the rest of the CLI uses and returns its
7
+ # exit code. `doctor` is a reserved name -- it never launches an agent.
8
+ module CLIDoctor
9
+ private
10
+
11
+ def doctor_command
12
+ Doctor.new(config: Config.new(env: @env), stdout: @stdout, stdin: @stdin).run
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/letsdo/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'cli/init'
4
+ require_relative 'cli/doctor'
4
5
  require_relative 'cli/builder'
5
6
 
6
7
  module Letsdo
@@ -10,6 +11,8 @@ module Letsdo
10
11
  # letsdo — usage and agent list, exit code 1;
11
12
  # letsdo --version — version, exit code 0;
12
13
  # letsdo --help — usage, exit code 0;
14
+ # letsdo doctor — environment self-check, exit 0 unless a
15
+ # check FAILs (then 1); `doctor` is reserved;
13
16
  # letsdo <name> — run the <name> agent in the orchestrator
14
17
  # loop until SIGINT/SIGTERM;
15
18
  # letsdo <name> (no prompt) — same, but on the built-in default prompt;
@@ -21,6 +24,7 @@ module Letsdo
21
24
  # letsdo <unknown option> — "letsdo: unknown option: X" + usage, exit 1.
22
25
  class CLI
23
26
  include CLIInit
27
+ include CLIDoctor
24
28
 
25
29
  USAGE = 'Usage: letsdo <agent_name>'
26
30
  AGENTS_HEADER = 'Available agents:'
@@ -34,6 +38,8 @@ module Letsdo
34
38
  def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
35
39
  @stdout = stdout
36
40
  @stderr = stderr
41
+ @env = env
42
+ @stdin = stdin
37
43
  @root = Config.new(env: env).root
38
44
  @builder = Builder.new(env: env, stdout: stdout, stderr: stderr,
39
45
  stdin: stdin, sleeper: sleeper)
@@ -44,14 +50,22 @@ module Letsdo
44
50
  return print_version if version_flag?(arg)
45
51
  return print_help if help_flag?(arg)
46
52
  return usage_error if arg.nil?
53
+ return doctor_command if arg == 'doctor'
47
54
  return init_command(argv) if argv.include?('--init')
48
55
  return unknown_option(arg) if arg.start_with?('-')
49
56
 
50
- @builder.run(arg)
57
+ run_agent(arg)
51
58
  end
52
59
 
53
60
  private
54
61
 
62
+ def run_agent(arg)
63
+ @builder.run(arg)
64
+ rescue Letsdo::BackendUnavailableError => e
65
+ @stderr.puts("letsdo: #{e.message}")
66
+ 2
67
+ end
68
+
55
69
  def version_flag?(arg)
56
70
  ['--version', '-v'].include?(arg)
57
71
  end
data/lib/letsdo/config.rb CHANGED
@@ -4,8 +4,8 @@ require 'shellwords'
4
4
 
5
5
  module Letsdo
6
6
  # The single place where every LETSDO_*/AGENT_* environment variable is
7
- # read, with exactly the defaults and precedence the CLI, PiRunner and
8
- # AgentLoop used to apply inline. Giving env policy one home makes it
7
+ # read, with exactly the defaults and precedence the CLI, the Pi backend
8
+ # and AgentLoop used to apply inline. Giving env policy one home makes it
9
9
  # testable in one place and gives future knobs (provider/backend
10
10
  # selection) a single spot to add a variable.
11
11
  #
@@ -14,6 +14,10 @@ module Letsdo
14
14
  DEFAULT_WAIT_SECONDS = 10.0
15
15
  DEFAULT_PI_COMMAND = 'pi'
16
16
  DEFAULT_BACKLOG_COMMAND = 'backlog'
17
+ DEFAULT_PROVIDER = 'backlog'
18
+ DEFAULT_BACKEND = 'pi'
19
+ DEFAULT_MAX_RETRIES = 3
20
+ DEFAULT_RETRY_CAP = 300.0
17
21
 
18
22
  def initialize(env: ENV)
19
23
  @env = env
@@ -34,11 +38,22 @@ module Letsdo
34
38
  Shellwords.split(value)
35
39
  end
36
40
 
37
- # The agent's backlog assignee handle. Default: @<name> (handle = name);
38
- # a blank override also falls back to the derived handle.
41
+ # The agent's backlog assignee name. Default: the bare agent name —
42
+ # the tracker stores bare names ('developer'); the '@' prefix is
43
+ # prompt-only notation (TASK-96). AGENT_ASSIGNEE_HANDLE overrides for
44
+ # legacy setups (used verbatim; a @-prefixed value still matches after
45
+ # normalization in the provider, but `letsdo doctor` warns about it);
46
+ # a blank override also falls back to the derived bare name.
39
47
  def assignee_handle(name)
40
- handle = @env['AGENT_ASSIGNEE_HANDLE']
41
- handle && !handle.strip.empty? ? handle : "@#{name}"
48
+ assignee_handle_override || name.to_s
49
+ end
50
+
51
+ # Raw AGENT_ASSIGNEE_HANDLE (nil when unset or blank) — `letsdo
52
+ # doctor` uses it to warn about a legacy @-prefixed override instead
53
+ # of it silently missing every bare-named task.
54
+ def assignee_handle_override
55
+ value = @env['AGENT_ASSIGNEE_HANDLE']
56
+ value && !value.strip.empty? ? value : nil
42
57
  end
43
58
 
44
59
  # Retry interval when no tasks are open. LETSDO_WAIT_SECONDS wins, then
@@ -53,7 +68,7 @@ module Letsdo
53
68
  DEFAULT_WAIT_SECONDS
54
69
  end
55
70
 
56
- # The pi command used to run agents (same literal as PiRunner::COMMAND).
71
+ # The pi command used to run agents (same literal as Backends::Pi::COMMAND).
57
72
  def pi_command
58
73
  @env.fetch('LETSDO_PI_COMMAND', DEFAULT_PI_COMMAND)
59
74
  end
@@ -63,9 +78,75 @@ module Letsdo
63
78
  @env.fetch('LETSDO_BACKLOG_COMMAND', DEFAULT_BACKLOG_COMMAND)
64
79
  end
65
80
 
66
- # Whether [letsdo] traces are enabled in PiRunner and AgentLoop.
81
+ # Executable search path, used by `letsdo doctor` to resolve the
82
+ # configured command names. It lives here so doctor code never reads
83
+ # ENV directly (TASK-71).
84
+ def path
85
+ @env.fetch('PATH', '')
86
+ end
87
+
88
+ # Environment for CLI child processes spawned outside the builder
89
+ # (the doctor's handle-less provider): the process environment
90
+ # overlaid with the injected env, so shebang PATH resolution and test
91
+ # scenario variables both keep working (TASK-96).
92
+ def child_env
93
+ ENV.to_h.merge(@env)
94
+ end
95
+
96
+ # The task provider name. Reads LETSDO_PROVIDER with default 'backlog'.
97
+ # An empty value falls back to 'backlog'.
98
+ def provider
99
+ @env['LETSDO_PROVIDER'].to_s.strip.empty? ? DEFAULT_PROVIDER : @env['LETSDO_PROVIDER'].to_s.strip
100
+ end
101
+
102
+ # The AI backend name. Reads LETSDO_BACKEND with default 'pi'.
103
+ # An empty value falls back to 'pi'.
104
+ def backend
105
+ @env['LETSDO_BACKEND'].to_s.strip.empty? ? DEFAULT_BACKEND : @env['LETSDO_BACKEND'].to_s.strip
106
+ end
107
+
108
+ # Whether [letsdo] traces are enabled in the Pi backend and AgentLoop.
67
109
  def debug?
68
110
  @env['LETSDO_DEBUG'] == '1'
69
111
  end
112
+
113
+ # Whether per-task elapsed is written back into the task record as a
114
+ # backlog comment at session stop (TASK-70). Opt-in: only the literal
115
+ # '1' enables it, so no task file is ever modified by default.
116
+ def task_time_comment?
117
+ @env['LETSDO_TASK_TIME_COMMENT'] == '1'
118
+ end
119
+
120
+ # Give-up after N consecutive failed runs of the same task in a session
121
+ # (default 3). Any invalid value falls back to the default.
122
+ def max_retries
123
+ value = @env['LETSDO_MAX_RETRIES'].to_s.strip
124
+ return DEFAULT_MAX_RETRIES if value.empty?
125
+
126
+ Integer(value)
127
+ rescue ArgumentError, TypeError
128
+ DEFAULT_MAX_RETRIES
129
+ end
130
+
131
+ # Base backoff seconds for the first retry; doubles per failure.
132
+ # Default = LETSDO_WAIT_SECONDS (the loop poll interval). An invalid
133
+ # value falls back to that default.
134
+ def retry_base
135
+ value = @env['LETSDO_RETRY_BASE'].to_s.strip
136
+ value.empty? ? wait_seconds : Float(value)
137
+ rescue ArgumentError, TypeError
138
+ wait_seconds
139
+ end
140
+
141
+ # Maximum backoff seconds (default 300). An invalid value falls back
142
+ # to the default.
143
+ def retry_cap
144
+ value = @env['LETSDO_RETRY_CAP'].to_s.strip
145
+ return DEFAULT_RETRY_CAP if value.empty?
146
+
147
+ Float(value)
148
+ rescue ArgumentError, TypeError
149
+ DEFAULT_RETRY_CAP
150
+ end
70
151
  end
71
152
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Control
5
+ # The plain-mode control reader (TASK-94): when letsdo runs in plain
6
+ # line-stream mode (stdout not a TTY) but stdin IS a terminal, a
7
+ # background thread reads Enter-terminated commands and maps them to
8
+ # the same actions as the TUI keys (TASK-67/74 control model):
9
+ #
10
+ # p - toggle pause: on -> Control::PauseGate#pause plus the running
11
+ # backend's SIGSTOP (a no-op when nothing runs), off -> resume;
12
+ # q - clean stop: Thread.main.raise(Letsdo::Stopped), the exact
13
+ # teardown path of SIGINT/SIGTERM and the TUI 'q'.
14
+ #
15
+ # The reader only starts for a terminal stdin: with a pipe or
16
+ # /dev/null it never reads a single byte, so a user's piped stdin is
17
+ # left untouched and stopping stays signal-only. It writes nothing
18
+ # (no echo, no escape codes, no partial output) and its thread exits
19
+ # quietly on EOF or a read error.
20
+ class Reader
21
+ # @param input [IO] the stdin stream to read lines from
22
+ # @param pause_gate [Letsdo::Control::PauseGate, nil] the between-runs
23
+ # gate toggled by 'p'
24
+ # @param runner [#call, nil] callable returning the current backend
25
+ # (answers pause/resume) or nil; mirrors the TUI wiring, so a
26
+ # nil between runs is simply a no-op
27
+ # @param on_stop [#call, nil] stop action; defaults to raising
28
+ # Letsdo::Stopped into the main thread (injected in tests)
29
+ # @param tty [Boolean, nil] overrides the input.tty? check (tests only)
30
+ def initialize(input:, pause_gate: nil, runner: nil, on_stop: nil, tty: nil)
31
+ @input = input
32
+ @pause_gate = pause_gate
33
+ @runner = runner
34
+ @on_stop = on_stop
35
+ @tty = tty
36
+ @paused = false
37
+ @thread = nil
38
+ end
39
+
40
+ # Whether the reader may run: stdin must be a terminal that can be
41
+ # read line by line, otherwise a pipe, /dev/null or CI stdin would be
42
+ # read from (and stolen from the user's pipe).
43
+ #
44
+ # @return [Boolean]
45
+ def available?
46
+ @tty.nil? ? terminal_input? : @tty
47
+ end
48
+
49
+ # Starts the background reader thread. A non-terminal stdin starts
50
+ # nothing at all.
51
+ #
52
+ # @return [Thread, nil] the reader thread, or nil when not a terminal
53
+ def start
54
+ return nil unless available?
55
+ return @thread if @thread
56
+
57
+ @thread = build_thread
58
+ end
59
+
60
+ # Waits for the reader thread so tests and shutdown can join it.
61
+ #
62
+ # @param timeout [Numeric, nil] seconds to wait, nil waits forever
63
+ # @return [Thread, nil]
64
+ def join(timeout = nil)
65
+ @thread&.join(timeout)
66
+ end
67
+
68
+ # Ends the reader: kills the background thread when it is still
69
+ # blocked on input, so no reader outlives the run that started it.
70
+ # Safe to call when it was never started (non-TTY stdin).
71
+ #
72
+ # @return [void]
73
+ def stop
74
+ thread = @thread
75
+ return unless thread
76
+
77
+ thread.kill
78
+ thread.join(1)
79
+ @thread = nil
80
+ end
81
+
82
+ private
83
+
84
+ def terminal_input?
85
+ @input.respond_to?(:tty?) && @input.tty? && @input.respond_to?(:gets)
86
+ end
87
+
88
+ def build_thread
89
+ Thread.new { read_loop }.tap { |thread| thread.report_on_exception = false }
90
+ end
91
+
92
+ # Reads Enter-terminated commands until EOF or a read error; never
93
+ # writes and never lets an error escape the thread.
94
+ def read_loop
95
+ while (line = @input.gets)
96
+ handle_command(line)
97
+ end
98
+ rescue IOError, SystemCallError
99
+ nil
100
+ end
101
+
102
+ def handle_command(line)
103
+ case line.strip
104
+ when 'p' then toggle_pause
105
+ when 'q' then request_stop
106
+ end
107
+ end
108
+
109
+ def toggle_pause
110
+ @paused = !@paused
111
+ action = @paused ? :pause : :resume
112
+ invoke(@pause_gate, action)
113
+ invoke(current_runner, action)
114
+ end
115
+
116
+ def current_runner
117
+ @runner.respond_to?(:call) ? @runner.call : @runner
118
+ end
119
+
120
+ def invoke(target, action)
121
+ return unless target.respond_to?(action)
122
+
123
+ target.public_send(action)
124
+ end
125
+
126
+ def request_stop
127
+ (@on_stop || -> { Thread.main.raise(Letsdo::Stopped) }).call
128
+ end
129
+ end
130
+ end
131
+ end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'control/reader'
4
+
3
5
  module Letsdo
4
- # Agent-control primitives shared by the TUI and the orchestrator loop
5
- # (TASK-67 control model): pausing between runs.
6
+ # Agent-control primitives shared by the TUI, the plain-mode reader and
7
+ # the orchestrator loop (TASK-67 control model): pausing between runs
8
+ # (PauseGate) and the stdin control reader (Reader).
6
9
  module Control
7
10
  # A thread-safe pause flag polled by Letsdo::AgentLoop between runs.
8
11
  #
9
- # Mid-run suspension is handled by Letsdo::PiRunner#pause (SIGSTOP to
12
+ # Mid-run suspension is handled by Letsdo::Backends::Pi#pause (SIGSTOP to
10
13
  # the pi group, kernel-level freeze). Between runs there is no pi to
11
14
  # stop, so the pause lives in this gate: while #paused? is true the
12
15
  # loop must not start a new run, and it waits until #resume.