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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Letsdo
6
+ module Tui
7
+ # Names the tmux window after the running agent (TASK-90) and restores
8
+ # the previous label on exit.
9
+ #
10
+ # tmux labels a window after its foreground process (automatic-rename),
11
+ # which for letsdo is always "ruby" — useless when one agent runs per
12
+ # pane. Two tmux facts (verified against tmux 3.4) shape this class:
13
+ #
14
+ # * an OSC-2 title escape only sets the *pane* title; the window label
15
+ # still follows the process name;
16
+ # * so the window must be renamed explicitly and automatic-rename
17
+ # disabled for the session, or tmux overwrites the label.
18
+ #
19
+ # Every tmux call goes through an injectable runner (tests stub it) and
20
+ # every failure is swallowed: title handling may never break a session.
21
+ # Being inside tmux on a TTY stream is the gate, so outside tmux nothing
22
+ # is written and the tmux binary is never invoked.
23
+ class WindowTitle
24
+ # OSC-2 title escape; only emitted inside tmux (see #install).
25
+ TITLE_TEMPLATE = "\e]2;%s\a"
26
+ # automatic-rename value meaning "no explicit setting" (tmux default on).
27
+ UNSET = ''
28
+
29
+ # @param stream [IO] terminal output stream (title escapes go here)
30
+ # @param env [Hash, ENV] source of TMUX / TMUX_PANE
31
+ # @param runner [Proc, nil] callable(Array<String>) → stdout String;
32
+ # defaults to the real tmux binary, stubbed in tests
33
+ def initialize(stream:, env:, runner: nil)
34
+ @stream = stream
35
+ @env = env
36
+ @runner = runner || method(:run_tmux)
37
+ @previous = nil
38
+ end
39
+
40
+ # Saves the current window label and labels the window with `name`.
41
+ # No-op outside tmux, or when the stream is not a TTY.
42
+ def install(name)
43
+ return unless tmux?
44
+
45
+ state = capture
46
+ return unless state
47
+
48
+ label(name)
49
+ write_title(name)
50
+ @previous = state
51
+ end
52
+
53
+ # Puts the saved label back and restores automatic-rename, unsetting
54
+ # the option when it had no explicit value before (no leftover state).
55
+ def restore
56
+ state = @previous
57
+ return unless state
58
+
59
+ @previous = nil
60
+ label(state[:name])
61
+ restore_automatic_rename(state[:automatic_rename])
62
+ write_title(state[:name])
63
+ end
64
+
65
+ # Whether a tmux session hosts this process and titles can be set.
66
+ def tmux?
67
+ return false unless @stream.respond_to?(:tty?) && @stream.tty?
68
+
69
+ !@env['TMUX'].to_s.empty?
70
+ end
71
+
72
+ private
73
+
74
+ # Renames the window, then disables automatic-rename so tmux keeps the
75
+ # label for the whole session.
76
+ def label(name)
77
+ tmux('rename-window', name)
78
+ tmux('set-option', '-w', 'automatic-rename', 'off')
79
+ end
80
+
81
+ def restore_automatic_rename(value)
82
+ return tmux('set-option', '-w', '-u', 'automatic-rename') if value == UNSET
83
+
84
+ tmux('set-option', '-w', 'automatic-rename', value)
85
+ end
86
+
87
+ # The current window label and automatic-rename value, or nil when the
88
+ # label cannot be read (no tmux, no server, failed command).
89
+ def capture
90
+ name = tmux('display-message', '-p', "\#{window_name}").to_s.strip
91
+ return nil if name.empty?
92
+
93
+ { name: name, automatic_rename: automatic_rename_option }
94
+ end
95
+
96
+ def automatic_rename_option
97
+ tmux('show-window-options', '-v', 'automatic-rename').to_s.strip
98
+ end
99
+
100
+ def write_title(text)
101
+ @stream.write(format(TITLE_TEMPLATE, text))
102
+ @stream.flush
103
+ end
104
+
105
+ # One tmux command through the injected runner; a missing binary, an
106
+ # unreachable server or a failed command all yield nil.
107
+ def tmux(*args)
108
+ @runner.call(window_args(*args))
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ # Targets the pane letsdo runs in, so the commands act on the right
114
+ # window even when the tmux server has no attached client. `-t` is a
115
+ # command option and must follow the subcommand.
116
+ def window_args(*args)
117
+ pane = @env['TMUX_PANE'].to_s
118
+ return args if pane.empty?
119
+
120
+ [args.first, '-t', pane, *args.drop(1)]
121
+ end
122
+
123
+ # @param args [Array<String>] tmux arguments
124
+ # @return [String] the command's stdout
125
+ def run_tmux(args)
126
+ out, _err, status = Open3.capture3('tmux', *args)
127
+ raise "tmux failed: #{args.join(' ')}" unless status.success?
128
+
129
+ out
130
+ end
131
+ end
132
+ end
133
+ end
data/lib/letsdo/tui.rb CHANGED
@@ -7,6 +7,8 @@
7
7
  # timer, tasks remaining, current-task elapsed, waiting/PAUSED states), a
8
8
  # scrollable combined log and a key-help footer, and repaints from a
9
9
  # background input thread. Non-TTY output stays the plain line-stream.
10
+ # Inside tmux the window is labeled with the agent name for the session and
11
+ # the previous label is restored on exit (TASK-90).
10
12
  #
11
13
  # Components:
12
14
  # Letsdo::Tui::LogBuffer - thread-safe combined log (streamer target)
@@ -14,11 +16,13 @@
14
16
  # Letsdo::Tui::Renderer - pure function: state → framed String
15
17
  # Letsdo::Tui::Terminal - alt screen, cursor, frame rendering
16
18
  # Letsdo::Tui::Input - tty-reader key decoding (injectable)
19
+ # Letsdo::Tui::WindowTitle - names the tmux window after the agent
17
20
  # Letsdo::Tui::Session - controller: terminal lifecycle + input thread
18
21
 
19
22
  require_relative 'tui/log_buffer'
20
23
  require_relative 'tui/metrics'
21
24
  require_relative 'tui/renderer'
22
25
  require_relative 'tui/terminal'
26
+ require_relative 'tui/window_title'
23
27
  require_relative 'tui/input'
24
28
  require_relative 'tui/session'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Letsdo
4
- VERSION = '0.4.0'
4
+ VERSION = '0.6.1'
5
5
  end
data/lib/letsdo.rb CHANGED
@@ -6,33 +6,44 @@
6
6
  # Letsdo::Errors - error hierarchy (Letsdo::Error and others)
7
7
  # Letsdo::PromptStore - access to agents/*.md prompts
8
8
  # Letsdo::DefaultPrompt - the built-in default prompt (fallback run, --init template)
9
+ # Letsdo::AgentIdentity - the identity block injected into every agent prompt
9
10
  # Letsdo::OutputStreamer - where and how agent text and service lines are printed
10
- # Letsdo::PiRunner - running pi --mode json and parsing the event stream
11
+ # Letsdo::Backends::Pi - running pi --mode json and parsing the event stream
11
12
  # Letsdo::Agent - a single agent run: prompt from agents/ or default + pi
12
13
  # Letsdo::Capture - interruption-safe child stdout/stderr capture
13
14
  # Letsdo::BacklogTasks - open tasks from the backlog CLI (the task provider)
14
15
  # Letsdo::Loop - generic orchestrator: tasks → runs → waiting
16
+ # Letsdo::RetryPolicy - per-task retry/backoff/give-up policy (used at AgentLoop level)
15
17
  # Letsdo::AgentLoop - letsdo wiring: provider + agent + signals + messages
16
- # Letsdo::Control - agent-control primitives (PauseGate)
18
+ # Letsdo::Control - agent-control primitives (PauseGate, Reader)
19
+ # Letsdo::Doctor - `letsdo doctor` environment self-check
17
20
  # Letsdo::Tui - the interactive TUI (LogBuffer, Metrics,
18
- # Renderer, Terminal, Input, Session)
21
+ # Renderer, Terminal, WindowTitle, Input, Session)
19
22
  # Letsdo::CLI - command-line arguments, usage, exit code
20
23
  #
21
24
  # Entry point — bin/letsdo (a thin wrapper over Letsdo::CLI).
22
25
 
23
26
  require_relative 'letsdo/version'
24
27
  require_relative 'letsdo/errors'
28
+ require_relative 'letsdo/duration'
25
29
  require_relative 'letsdo/config'
26
30
  require_relative 'letsdo/prompt_store'
27
31
  require_relative 'letsdo/default_prompt'
32
+ require_relative 'letsdo/agent_identity'
28
33
  require_relative 'letsdo/output_streamer'
29
- require_relative 'letsdo/pi_runner'
34
+ require_relative 'letsdo/backends/backend'
35
+ require_relative 'letsdo/backends/pi'
30
36
  require_relative 'letsdo/agent'
31
37
  require_relative 'letsdo/capture'
32
- require_relative 'letsdo/backlog_tasks'
38
+ require_relative 'letsdo/providers/backlog'
33
39
  require_relative 'letsdo/loop'
40
+ require_relative 'letsdo/retry_policy'
34
41
  require_relative 'letsdo/watcher'
35
42
  require_relative 'letsdo/agent_loop'
43
+ require_relative 'letsdo/session_recorder'
44
+ require_relative 'letsdo/task_time_writeback'
45
+ require_relative 'letsdo/metrics/fanout'
36
46
  require_relative 'letsdo/control'
47
+ require_relative 'letsdo/doctor'
37
48
  require_relative 'letsdo/tui'
38
49
  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.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergei O. Udalov
@@ -95,27 +95,46 @@ files:
95
95
  - LICENSE
96
96
  - README.md
97
97
  - bin/letsdo
98
+ - docs/config.md
99
+ - docs/prompts.md
100
+ - docs/task-selection.md
101
+ - docs/usage.md
98
102
  - letsdo.gemspec
99
103
  - lib/letsdo.rb
100
104
  - lib/letsdo/agent.rb
105
+ - lib/letsdo/agent_identity.rb
101
106
  - lib/letsdo/agent_loop.rb
107
+ - lib/letsdo/agent_loop/assignee_hints.rb
102
108
  - lib/letsdo/agent_loop/tasks.rb
103
- - lib/letsdo/backlog_tasks.rb
109
+ - lib/letsdo/backends/backend.rb
110
+ - lib/letsdo/backends/pi.rb
111
+ - lib/letsdo/backends/pi/events.rb
104
112
  - lib/letsdo/capture.rb
105
113
  - lib/letsdo/cli.rb
106
114
  - lib/letsdo/cli/builder.rb
115
+ - lib/letsdo/cli/builder_assembly.rb
116
+ - lib/letsdo/cli/builder_metrics.rb
117
+ - lib/letsdo/cli/doctor.rb
107
118
  - lib/letsdo/cli/init.rb
108
119
  - lib/letsdo/config.rb
109
120
  - lib/letsdo/control.rb
121
+ - lib/letsdo/control/reader.rb
110
122
  - lib/letsdo/default_prompt.rb
123
+ - lib/letsdo/doctor.rb
124
+ - lib/letsdo/doctor/checks.rb
125
+ - lib/letsdo/duration.rb
111
126
  - lib/letsdo/errors.rb
112
127
  - lib/letsdo/loop.rb
128
+ - lib/letsdo/metrics/fanout.rb
113
129
  - lib/letsdo/output_streamer.rb
114
130
  - lib/letsdo/output_streamer/arg_summary.rb
115
- - lib/letsdo/pi_runner.rb
116
- - lib/letsdo/pi_runner/events.rb
117
- - lib/letsdo/pi_runner/process.rb
118
131
  - lib/letsdo/prompt_store.rb
132
+ - lib/letsdo/providers/backlog.rb
133
+ - lib/letsdo/providers/task.rb
134
+ - lib/letsdo/retry_policy.rb
135
+ - lib/letsdo/session_recorder.rb
136
+ - lib/letsdo/session_recorder/jsonl_writer.rb
137
+ - lib/letsdo/task_time_writeback.rb
119
138
  - lib/letsdo/tui.rb
120
139
  - lib/letsdo/tui/input.rb
121
140
  - lib/letsdo/tui/log_buffer.rb
@@ -123,8 +142,10 @@ files:
123
142
  - lib/letsdo/tui/renderer.rb
124
143
  - lib/letsdo/tui/session.rb
125
144
  - lib/letsdo/tui/session/keys.rb
145
+ - lib/letsdo/tui/session/terminal.rb
126
146
  - lib/letsdo/tui/session/view.rb
127
147
  - lib/letsdo/tui/terminal.rb
148
+ - lib/letsdo/tui/window_title.rb
128
149
  - lib/letsdo/version.rb
129
150
  - lib/letsdo/watcher.rb
130
151
  homepage: https://github.com/sergio-fry/letsdo
@@ -151,7 +172,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
151
172
  - !ruby/object:Gem::Version
152
173
  version: '0'
153
174
  requirements: []
154
- rubygems_version: 4.0.6
175
+ rubygems_version: 4.0.21
155
176
  specification_version: 4
156
177
  summary: A local agent worker for Backlog.md/markdown tasks
157
178
  test_files: []
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'shellwords'
5
-
6
- module Letsdo
7
- # Task provider for Letsdo::Loop backed by the real backlog CLI:
8
- #
9
- # backlog task list --assignee <handle> --exclude-status Done --json
10
- #
11
- # Returns the list of open tasks assigned to the handle (an Array, empty
12
- # when there are none), or nil when the backlog state is unreadable — the
13
- # CLI is not on PATH, failed, or its output is not the expected JSON. The
14
- # loop treats nil as "pause and retry, do not run the agent".
15
- #
16
- # The command runs in the project root (cwd), where the backlog CLI finds
17
- # the backlog/ folder — the same context as a single agent run.
18
- class BacklogTasks
19
- # @param handle [String] assignee handle to filter by (e.g. "@developer")
20
- # @param command [String] backlog CLI command (overridable for tests)
21
- # @param cwd [String, nil] project root for the CLI; nil = inherit cwd
22
- # @param env [Hash, nil] environment for the CLI child (nil = inherit
23
- # the process environment; injected in tests to control the
24
- # fake backlog scenarios)
25
- def initialize(handle:, command: 'backlog', cwd: nil, env: nil)
26
- @handle = handle
27
- @command = command
28
- @cwd = cwd
29
- @env = env
30
- end
31
-
32
- # Reads the open tasks once.
33
- #
34
- # @return [Array<Hash>, nil] open tasks; nil when the backlog is unreadable
35
- def call
36
- args = @env ? [@env, *command_line] : command_line
37
- out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
38
- return nil unless status.success?
39
-
40
- tasks = JSON.parse(out)['tasks']
41
- tasks.is_a?(Array) ? tasks : nil
42
- rescue Errno::ENOENT, JSON::ParserError, TypeError
43
- nil
44
- end
45
-
46
- private
47
-
48
- # [command..., task, list, --assignee <handle>, --exclude-status Done, --json]
49
- def command_line
50
- [
51
- *Shellwords.split(@command),
52
- 'task', 'list',
53
- '--assignee', @handle,
54
- '--exclude-status', 'Done',
55
- '--json'
56
- ]
57
- end
58
- end
59
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Letsdo
4
- # Pi JSON event-stream handlers used by PiRunner.
5
- module PiRunnerEvents
6
- private
7
-
8
- def read_pi_stream(out_r)
9
- out_r.each_line { |line| handle_line(line) }
10
- debug('stream: EOF')
11
- end
12
-
13
- def handle_line(line)
14
- line = line.strip
15
- return if line.empty?
16
-
17
- event = parse_event(line)
18
- dispatch_event(event) if event
19
- end
20
-
21
- def dispatch_event(event)
22
- case event['type']
23
- when 'message_update' then handle_message_update(event)
24
- when 'tool_execution_start' then handle_tool_start(event)
25
- when 'tool_execution_end' then handle_tool_execution_end(event)
26
- when 'agent_end' then flush_pending_tools
27
- end
28
- end
29
-
30
- def handle_tool_start(event)
31
- @pending_tools.delete(event['toolCallId'])
32
- @streamer.tool_start(event['toolName'] || 'tool', args: event['args'])
33
- end
34
-
35
- def handle_message_update(event)
36
- payload = event['assistantMessageEvent']
37
- return unless payload
38
-
39
- handle_text_delta(payload) || remember_toolcall(event, payload)
40
- end
41
-
42
- def handle_text_delta(payload)
43
- return unless payload['type'] == 'text_delta'
44
-
45
- delta = payload['delta']
46
- @streamer.text_delta(delta) if delta && !delta.empty?
47
- true
48
- end
49
-
50
- def remember_toolcall(event, payload)
51
- return unless payload['type'] == 'toolcall_start'
52
-
53
- id = event['id'] || payload['id']
54
- name = event['toolName'] || payload['toolName'] || 'tool'
55
- @pending_tools[id] = name unless id.nil?
56
- end
57
-
58
- def handle_tool_execution_end(event)
59
- name = event['toolName'] || 'tool'
60
- error = event['isError'] == true
61
- @streamer.tool_result(name, error: error)
62
- end
63
-
64
- def flush_pending_tools
65
- @pending_tools.each_value { |name| @streamer.tool_start(name) }
66
- @pending_tools.clear
67
- end
68
-
69
- def parse_event(line)
70
- JSON.parse(line)
71
- rescue JSON::ParserError
72
- nil
73
- end
74
- end
75
- end
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Letsdo
4
- # Process-group control for a running pi child.
5
- module PiRunnerProcess
6
- def terminate(signal: 'TERM', grace: 3.0, tick: 0.05)
7
- pid = @pid
8
- return true unless pid
9
-
10
- send_signal('CONT', pid)
11
- send_signal(signal, pid)
12
- wait_for_exit(pid, grace, tick)
13
- end
14
-
15
- private
16
-
17
- def wait_for_exit(pid, grace, tick)
18
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
19
- loop do
20
- status = wait_status(pid, nonblock: true)
21
- return status if status
22
- break unless alive?(pid)
23
- return kill_and_reap(pid, tick) if overdue_deadline?(deadline)
24
-
25
- sleep(tick)
26
- end
27
- true
28
- end
29
-
30
- def overdue_deadline?(deadline)
31
- Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
32
- end
33
-
34
- def kill_and_reap(pid, tick)
35
- send_signal('KILL', pid)
36
- sleep(tick)
37
- wait_status(pid, nonblock: true) || true
38
- end
39
-
40
- def wait_status(pid, nonblock: false)
41
- _, status = Process.wait2(pid, nonblock ? Process::WNOHANG : 0)
42
- @reaped_status = status if status
43
- status
44
- rescue Errno::ECHILD
45
- @reaped_status
46
- end
47
-
48
- def send_signal(signal, pid)
49
- Process.kill(signal, -pid)
50
- rescue Errno::ESRCH, Errno::EPERM
51
- nil
52
- end
53
-
54
- def alive?(pid)
55
- Process.kill(0, pid)
56
- true
57
- rescue Errno::ESRCH, Errno::EPERM
58
- false
59
- end
60
-
61
- def exit_code(status)
62
- return 1 if status.nil?
63
- return status.exitstatus if status.exitstatus
64
-
65
- status.termsig ? 128 + status.termsig : 1
66
- end
67
- end
68
- end
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
-
5
- module Letsdo
6
- # Runs pi in --mode json and hands events to the output streamer.
7
- class PiRunner
8
- COMMAND = 'pi'
9
- MODE = 'json'
10
- MESSAGE_UPDATE = 'message_update'
11
- TOOL_EXECUTION_START = 'tool_execution_start'
12
- TOOL_EXECUTION_END = 'tool_execution_end'
13
- AGENT_END = 'agent_end'
14
- end
15
- end
16
-
17
- require_relative 'pi_runner/events'
18
- require_relative 'pi_runner/process'
19
-
20
- module Letsdo
21
- # Pi process spawn, stream drain, and pause/resume signals.
22
- class PiRunner
23
- include PiRunnerEvents
24
- include PiRunnerProcess
25
-
26
- def initialize(prompt:, streamer:, **opts)
27
- @prompt = prompt
28
- @streamer = streamer
29
- @flags = opts.fetch(:flags, [])
30
- @command = opts.fetch(:command, COMMAND)
31
- @pending_tools = {}
32
- @reaped_status = nil
33
- @debug = opts[:debug].nil? ? (opts[:config] || Config.new).debug? : opts[:debug]
34
- end
35
-
36
- def run
37
- out_r = spawn_pi
38
- drain_stream(out_r)
39
- finish_run
40
- ensure
41
- @pid = nil
42
- @reaped_status = nil
43
- end
44
-
45
- def debug(message)
46
- warn("[letsdo] pi: #{message}") if @debug
47
- end
48
-
49
- def terminate_now
50
- pid = @pid
51
- send_signal('TERM', pid) if pid
52
- end
53
-
54
- def pause
55
- pid = @pid
56
- send_signal('STOP', pid) if pid
57
- end
58
-
59
- def resume
60
- pid = @pid
61
- send_signal('CONT', pid) if pid
62
- end
63
-
64
- private
65
-
66
- def spawn_pi
67
- cmd = [@command, '--mode', MODE, *@flags, @prompt]
68
- out_r, out_w = IO.pipe
69
- @pid = Process.spawn(*cmd, out: out_w, err: $stderr, pgroup: true)
70
- out_w.close
71
- debug("spawned pid=#{@pid} (own group)")
72
- out_r
73
- end
74
-
75
- def drain_stream(out_r)
76
- read_pi_stream(out_r)
77
- rescue Letsdo::Stopped
78
- debug('stopped by signal, terminating pi')
79
- terminate
80
- wait_status(@pid)
81
- @streamer.finish
82
- raise
83
- ensure
84
- close_pipe(out_r)
85
- flush_pending_tools
86
- end
87
-
88
- def close_pipe(out_r)
89
- out_r.close
90
- rescue IOError
91
- nil
92
- end
93
-
94
- def finish_run
95
- status = wait_status(@pid)
96
- @streamer.finish
97
- debug("exit status=#{status.inspect} code=#{exit_code(status)}")
98
- exit_code(status)
99
- end
100
- end
101
- end