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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -2
- data/README.md +150 -18
- data/bin/letsdo +8 -1
- data/docs/config.md +176 -0
- data/docs/prompts.md +210 -0
- data/docs/task-selection.md +244 -0
- data/docs/usage.md +296 -0
- data/letsdo.gemspec +3 -1
- data/lib/letsdo/agent.rb +30 -17
- data/lib/letsdo/agent_identity.rb +46 -0
- data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
- data/lib/letsdo/agent_loop/tasks.rb +88 -13
- data/lib/letsdo/agent_loop.rb +39 -3
- data/lib/letsdo/backends/backend.rb +137 -0
- data/lib/letsdo/backends/pi/events.rb +77 -0
- data/lib/letsdo/backends/pi.rb +88 -0
- data/lib/letsdo/cli/builder.rb +47 -95
- data/lib/letsdo/cli/builder_assembly.rb +154 -0
- data/lib/letsdo/cli/builder_metrics.rb +82 -0
- data/lib/letsdo/cli/doctor.rb +16 -0
- data/lib/letsdo/cli.rb +15 -1
- data/lib/letsdo/config.rb +89 -8
- data/lib/letsdo/control/reader.rb +131 -0
- data/lib/letsdo/control.rb +6 -3
- data/lib/letsdo/doctor/checks.rb +144 -0
- data/lib/letsdo/doctor.rb +42 -0
- data/lib/letsdo/duration.rb +23 -0
- data/lib/letsdo/errors.rb +7 -0
- data/lib/letsdo/metrics/fanout.rb +47 -0
- data/lib/letsdo/prompt_store.rb +48 -1
- data/lib/letsdo/providers/backlog.rb +180 -0
- data/lib/letsdo/providers/task.rb +48 -0
- data/lib/letsdo/retry_policy.rb +98 -0
- data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
- data/lib/letsdo/session_recorder.rb +188 -0
- data/lib/letsdo/task_time_writeback.rb +106 -0
- data/lib/letsdo/tui/metrics.rb +10 -3
- data/lib/letsdo/tui/renderer.rb +9 -1
- data/lib/letsdo/tui/session/terminal.rb +47 -0
- data/lib/letsdo/tui/session/view.rb +10 -2
- data/lib/letsdo/tui/session.rb +27 -22
- data/lib/letsdo/tui/window_title.rb +133 -0
- data/lib/letsdo/tui.rb +4 -0
- data/lib/letsdo/version.rb +1 -1
- data/lib/letsdo.rb +16 -5
- metadata +27 -6
- data/lib/letsdo/backlog_tasks.rb +0 -59
- data/lib/letsdo/pi_runner/events.rb +0 -75
- data/lib/letsdo/pi_runner/process.rb +0 -68
- 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'
|
data/lib/letsdo/version.rb
CHANGED
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::
|
|
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/
|
|
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/
|
|
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
|
+
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/
|
|
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.
|
|
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: []
|
data/lib/letsdo/backlog_tasks.rb
DELETED
|
@@ -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
|
data/lib/letsdo/pi_runner.rb
DELETED
|
@@ -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
|