pocketrb 0.1.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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- metadata +327 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Pocketrb
|
|
6
|
+
module Tools
|
|
7
|
+
# Manage scheduled cron jobs - allows agent to be proactive
|
|
8
|
+
class Cron < Base
|
|
9
|
+
def name
|
|
10
|
+
"cron"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def description
|
|
14
|
+
<<~DESC.strip
|
|
15
|
+
Manage scheduled tasks. Create reminders, follow-ups, or recurring checks.
|
|
16
|
+
Use this to be proactive - schedule nudges, daily check-ins, or one-time reminders.
|
|
17
|
+
DESC
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parameters
|
|
21
|
+
{
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
action: {
|
|
25
|
+
type: "string",
|
|
26
|
+
enum: %w[add list remove enable disable],
|
|
27
|
+
description: "Action: add (new job), list (show jobs), remove/enable/disable (manage job)"
|
|
28
|
+
},
|
|
29
|
+
name: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Job name/label (required for add)"
|
|
32
|
+
},
|
|
33
|
+
message: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Message to send when job runs (required for add)"
|
|
36
|
+
},
|
|
37
|
+
schedule_type: {
|
|
38
|
+
type: "string",
|
|
39
|
+
enum: %w[at every cron],
|
|
40
|
+
description: "Schedule type: 'at' (one-time), 'every' (interval), 'cron' (expression)"
|
|
41
|
+
},
|
|
42
|
+
schedule_value: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Schedule value: ISO datetime for 'at', seconds for 'every', cron expr for 'cron'"
|
|
45
|
+
},
|
|
46
|
+
job_id: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Job ID (required for remove/enable/disable)"
|
|
49
|
+
},
|
|
50
|
+
deliver: {
|
|
51
|
+
type: "boolean",
|
|
52
|
+
description: "If true, deliver message to channel. If false, just wake agent with message."
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
required: ["action"]
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def available?
|
|
60
|
+
!cron_service.nil?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def execute(action:, name: nil, message: nil, schedule_type: nil, schedule_value: nil, job_id: nil, deliver: true)
|
|
64
|
+
return error("Cron service not available. Start with --enable-cron flag.") unless cron_service
|
|
65
|
+
|
|
66
|
+
case action
|
|
67
|
+
when "add"
|
|
68
|
+
add_job(name, message, schedule_type, schedule_value, deliver)
|
|
69
|
+
when "list"
|
|
70
|
+
list_jobs
|
|
71
|
+
when "remove"
|
|
72
|
+
remove_job(job_id)
|
|
73
|
+
when "enable"
|
|
74
|
+
toggle_job(job_id, true)
|
|
75
|
+
when "disable"
|
|
76
|
+
toggle_job(job_id, false)
|
|
77
|
+
else
|
|
78
|
+
error("Unknown action: #{action}")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def cron_service
|
|
85
|
+
@context[:cron_service]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def current_channel
|
|
89
|
+
@context[:default_channel] || @context[:channel] || :telegram
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def current_chat_id
|
|
93
|
+
@context[:default_chat_id] || @context[:chat_id]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def add_job(name, message, schedule_type, schedule_value, deliver)
|
|
97
|
+
return error("Name required") unless name && !name.empty?
|
|
98
|
+
return error("Message required") unless message && !message.empty?
|
|
99
|
+
return error("Schedule type required (at, every, or cron)") unless schedule_type
|
|
100
|
+
|
|
101
|
+
schedule = build_schedule(schedule_type, schedule_value)
|
|
102
|
+
return schedule if schedule.is_a?(String) && schedule.start_with?("Error")
|
|
103
|
+
|
|
104
|
+
job = cron_service.add_job(
|
|
105
|
+
name: name,
|
|
106
|
+
schedule: schedule,
|
|
107
|
+
message: message,
|
|
108
|
+
deliver: deliver,
|
|
109
|
+
channel: current_channel.to_s,
|
|
110
|
+
to: current_chat_id
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
next_run = job.state.next_run_at_ms
|
|
114
|
+
next_run_time = Time.at(next_run / 1000).strftime("%Y-%m-%d %H:%M:%S") if next_run
|
|
115
|
+
|
|
116
|
+
success("Created job '#{name}' (ID: #{job.id}). Next run: #{next_run_time || "pending"}")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_schedule(type, value)
|
|
120
|
+
case type
|
|
121
|
+
when "at"
|
|
122
|
+
# One-time: parse ISO datetime
|
|
123
|
+
return error("Datetime required for 'at' schedule") unless value
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
time = Time.parse(value)
|
|
127
|
+
return error("Scheduled time must be in the future") if time <= Time.now
|
|
128
|
+
|
|
129
|
+
::Pocketrb::Cron::Schedule.new(kind: :at, at_ms: (time.to_f * 1000).to_i)
|
|
130
|
+
rescue ArgumentError => e
|
|
131
|
+
error("Invalid datetime: #{e.message}. Use ISO format like '2026-02-04T09:00:00'")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
when "every"
|
|
135
|
+
# Interval: parse seconds
|
|
136
|
+
return error("Interval in seconds required for 'every' schedule") unless value
|
|
137
|
+
|
|
138
|
+
seconds = value.to_i
|
|
139
|
+
return error("Interval must be at least 60 seconds") if seconds < 60
|
|
140
|
+
|
|
141
|
+
::Pocketrb::Cron::Schedule.new(kind: :every, every_ms: seconds * 1000)
|
|
142
|
+
|
|
143
|
+
when "cron"
|
|
144
|
+
# Cron expression
|
|
145
|
+
return error("Cron expression required") unless value
|
|
146
|
+
|
|
147
|
+
::Pocketrb::Cron::Schedule.new(kind: :cron, expr: value)
|
|
148
|
+
|
|
149
|
+
else
|
|
150
|
+
error("Unknown schedule type: #{type}")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def list_jobs
|
|
155
|
+
jobs = cron_service.list_jobs(include_disabled: true)
|
|
156
|
+
|
|
157
|
+
return "No scheduled jobs." if jobs.empty?
|
|
158
|
+
|
|
159
|
+
lines = ["Scheduled Jobs:\n"]
|
|
160
|
+
|
|
161
|
+
jobs.each do |job|
|
|
162
|
+
status = job.enabled ? "✓" : "✗"
|
|
163
|
+
next_run = if job.state.next_run_at_ms
|
|
164
|
+
Time.at(job.state.next_run_at_ms / 1000).strftime("%m/%d %H:%M")
|
|
165
|
+
else
|
|
166
|
+
"—"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
schedule_desc = format_schedule(job.schedule)
|
|
170
|
+
lines << "#{status} [#{job.id}] #{job.name}"
|
|
171
|
+
lines << " Schedule: #{schedule_desc}"
|
|
172
|
+
lines << " Next: #{next_run}"
|
|
173
|
+
lines << " Message: #{job.payload.message[0..50]}#{"..." if job.payload.message.length > 50}"
|
|
174
|
+
lines << ""
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
lines.join("\n")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def format_schedule(schedule)
|
|
181
|
+
case schedule.kind
|
|
182
|
+
when :at
|
|
183
|
+
time = Time.at(schedule.at_ms / 1000)
|
|
184
|
+
"One-time at #{time.strftime("%Y-%m-%d %H:%M")}"
|
|
185
|
+
when :every
|
|
186
|
+
seconds = schedule.every_ms / 1000
|
|
187
|
+
if seconds >= 86_400
|
|
188
|
+
"Every #{seconds / 86_400} day(s)"
|
|
189
|
+
elsif seconds >= 3600
|
|
190
|
+
"Every #{seconds / 3600} hour(s)"
|
|
191
|
+
else
|
|
192
|
+
"Every #{seconds / 60} minute(s)"
|
|
193
|
+
end
|
|
194
|
+
when :cron
|
|
195
|
+
"Cron: #{schedule.expr}"
|
|
196
|
+
else
|
|
197
|
+
"Unknown"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def remove_job(job_id)
|
|
202
|
+
return error("Job ID required") unless job_id
|
|
203
|
+
|
|
204
|
+
if cron_service.remove_job(job_id)
|
|
205
|
+
success("Removed job: #{job_id}")
|
|
206
|
+
else
|
|
207
|
+
error("Job not found: #{job_id}")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def toggle_job(job_id, enabled)
|
|
212
|
+
return error("Job ID required") unless job_id
|
|
213
|
+
|
|
214
|
+
if cron_service.enable_job(job_id, enabled: enabled)
|
|
215
|
+
success("Job #{job_id} #{enabled ? "enabled" : "disabled"}")
|
|
216
|
+
else
|
|
217
|
+
error("Job not found: #{job_id}")
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
module Tools
|
|
5
|
+
# Edit a file with search/replace
|
|
6
|
+
class EditFile < Base
|
|
7
|
+
def name
|
|
8
|
+
"edit_file"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"Edit a file by replacing specific text. The old_string must match exactly (including whitespace and indentation). Use for precise edits rather than rewriting entire files."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parameters
|
|
16
|
+
{
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
path: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Path to the file to edit"
|
|
22
|
+
},
|
|
23
|
+
old_string: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "The exact text to find and replace (must match exactly)"
|
|
26
|
+
},
|
|
27
|
+
new_string: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "The text to replace it with"
|
|
30
|
+
},
|
|
31
|
+
replace_all: {
|
|
32
|
+
type: "boolean",
|
|
33
|
+
description: "Replace all occurrences instead of just the first (default: false)"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
required: %w[path old_string new_string]
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def execute(path:, old_string:, new_string:, replace_all: false)
|
|
41
|
+
resolved = validate_path!(path)
|
|
42
|
+
|
|
43
|
+
return error("Not a file: #{path}") unless resolved.file?
|
|
44
|
+
|
|
45
|
+
content = File.read(resolved)
|
|
46
|
+
|
|
47
|
+
# Check if old_string exists
|
|
48
|
+
unless content.include?(old_string)
|
|
49
|
+
# Try to provide helpful feedback
|
|
50
|
+
return error(suggest_match(content, old_string))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check for uniqueness if not replace_all
|
|
54
|
+
if !replace_all && content.scan(old_string).length > 1
|
|
55
|
+
return error("old_string is not unique in the file (found #{content.scan(old_string).length} occurrences). Use replace_all: true or provide a more specific match.")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Perform replacement
|
|
59
|
+
new_content = if replace_all
|
|
60
|
+
content.gsub(old_string, new_string)
|
|
61
|
+
else
|
|
62
|
+
content.sub(old_string, new_string)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Write back
|
|
66
|
+
File.write(resolved, new_content)
|
|
67
|
+
|
|
68
|
+
count = replace_all ? content.scan(old_string).length : 1
|
|
69
|
+
success("Replaced #{count} occurrence(s) in #{path}")
|
|
70
|
+
rescue Errno::ENOENT
|
|
71
|
+
error("File not found: #{path}")
|
|
72
|
+
rescue Errno::EACCES
|
|
73
|
+
error("Permission denied: #{path}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def suggest_match(content, old_string)
|
|
79
|
+
# Try to find similar content
|
|
80
|
+
lines = content.lines
|
|
81
|
+
old_lines = old_string.lines
|
|
82
|
+
|
|
83
|
+
return "old_string not found in file" if old_lines.empty?
|
|
84
|
+
|
|
85
|
+
first_line = old_lines.first.strip
|
|
86
|
+
|
|
87
|
+
# Find lines containing the first line's content
|
|
88
|
+
matches = lines.each_with_index.filter_map do |line, idx|
|
|
89
|
+
[line, idx + 1] if line.include?(first_line) || first_line.include?(line.strip)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if matches.any?
|
|
93
|
+
match_info = matches.first(3).map { |line, num| "Line #{num}: #{line.strip[0..60]}" }.join("\n")
|
|
94
|
+
"old_string not found. Similar content found at:\n#{match_info}\n\nCheck for whitespace or indentation differences."
|
|
95
|
+
else
|
|
96
|
+
"old_string not found in file. Ensure the text matches exactly, including all whitespace and indentation."
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Pocketrb
|
|
7
|
+
module Tools
|
|
8
|
+
# Execute shell commands with smart timeout and background job support
|
|
9
|
+
class Exec < Base
|
|
10
|
+
# Per-operation timeouts (seconds)
|
|
11
|
+
TIMEOUTS = {
|
|
12
|
+
simple: 30, # Quick commands (ls, cat, echo)
|
|
13
|
+
standard: 120, # Normal shell commands
|
|
14
|
+
complex: 300 # Builds, installs
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT = TIMEOUTS[:standard]
|
|
18
|
+
MAX_OUTPUT_SIZE = 100_000
|
|
19
|
+
|
|
20
|
+
# Quick commands that should finish fast
|
|
21
|
+
QUICK_PATTERNS = [
|
|
22
|
+
/^(ls|cat|head|tail|echo|pwd|whoami|date|hostname|env)\b/i,
|
|
23
|
+
/^cd\s/i,
|
|
24
|
+
/^which\s/i,
|
|
25
|
+
/^type\s/i,
|
|
26
|
+
/^file\s/i,
|
|
27
|
+
/^stat\s/i,
|
|
28
|
+
/^test\s/i,
|
|
29
|
+
/^\[/ # test bracket syntax
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
def name
|
|
33
|
+
"exec"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def description
|
|
37
|
+
"Execute a shell command. Long-running commands (apt install, npm install, etc.) auto-run in background. Use for git, npm, make, and other development tools."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parameters
|
|
41
|
+
{
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
command: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "The shell command to execute"
|
|
47
|
+
},
|
|
48
|
+
timeout: {
|
|
49
|
+
type: "integer",
|
|
50
|
+
description: "Timeout in seconds (default: auto-detected, max: 600)"
|
|
51
|
+
},
|
|
52
|
+
working_dir: {
|
|
53
|
+
type: "string",
|
|
54
|
+
description: "Working directory for the command (defaults to workspace)"
|
|
55
|
+
},
|
|
56
|
+
background: {
|
|
57
|
+
type: "boolean",
|
|
58
|
+
description: "Force run in background (default: auto-detected for long commands)"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
required: ["command"]
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def execute(command:, timeout: nil, working_dir: nil, background: nil)
|
|
66
|
+
work_dir = resolve_working_dir(working_dir)
|
|
67
|
+
return work_dir if work_dir.is_a?(String) && work_dir.start_with?("Error:")
|
|
68
|
+
|
|
69
|
+
return error("Command blocked for security reasons") if dangerous_command?(command)
|
|
70
|
+
|
|
71
|
+
# Determine if should run in background
|
|
72
|
+
run_background = background.nil? ? job_manager.long_running?(command) : background
|
|
73
|
+
|
|
74
|
+
if run_background
|
|
75
|
+
execute_background(command, work_dir)
|
|
76
|
+
else
|
|
77
|
+
execute_foreground(command, work_dir, timeout)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def job_manager
|
|
84
|
+
@job_manager ||= BackgroundJobManager.new(workspace: workspace)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def resolve_working_dir(working_dir)
|
|
88
|
+
if working_dir
|
|
89
|
+
resolved = resolve_path(working_dir)
|
|
90
|
+
return error("Working directory outside workspace: #{working_dir}") unless path_allowed?(working_dir)
|
|
91
|
+
|
|
92
|
+
resolved.to_s
|
|
93
|
+
else
|
|
94
|
+
workspace&.to_s || Dir.pwd
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def execute_background(command, work_dir)
|
|
99
|
+
Pocketrb.logger.debug("Running in background: #{command}")
|
|
100
|
+
|
|
101
|
+
result = job_manager.start(
|
|
102
|
+
command: command,
|
|
103
|
+
working_dir: work_dir,
|
|
104
|
+
name: command[0..50]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
success(<<~OUTPUT)
|
|
108
|
+
Started in background (detected long-running command)
|
|
109
|
+
Job ID: #{result[:job_id]}
|
|
110
|
+
PID: #{result[:pid]}
|
|
111
|
+
Log: #{result[:log_file]}
|
|
112
|
+
|
|
113
|
+
Use 'jobs' tool to check status and output.
|
|
114
|
+
OUTPUT
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def execute_foreground(command, work_dir, explicit_timeout)
|
|
118
|
+
timeout = explicit_timeout || smart_timeout(command)
|
|
119
|
+
timeout = [timeout, 600].min if timeout
|
|
120
|
+
|
|
121
|
+
Pocketrb.logger.debug("Executing: #{command} in #{work_dir} (timeout: #{timeout || "none"})")
|
|
122
|
+
|
|
123
|
+
stdout, stderr, status = nil
|
|
124
|
+
|
|
125
|
+
begin
|
|
126
|
+
if timeout
|
|
127
|
+
Timeout.timeout(timeout) do
|
|
128
|
+
stdout, stderr, status = Open3.capture3(command, chdir: work_dir)
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
stdout, stderr, status = Open3.capture3(command, chdir: work_dir)
|
|
132
|
+
end
|
|
133
|
+
rescue Timeout::Error
|
|
134
|
+
return error("Command timed out after #{timeout} seconds. Consider using background: true for long commands.")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
format_result(stdout, stderr, status)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def smart_timeout(command)
|
|
141
|
+
return TIMEOUTS[:simple] if quick_command?(command)
|
|
142
|
+
return nil if job_manager.long_running?(command)
|
|
143
|
+
|
|
144
|
+
TIMEOUTS[:standard]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def quick_command?(command)
|
|
148
|
+
return false if command.nil? || command.empty?
|
|
149
|
+
|
|
150
|
+
QUICK_PATTERNS.any? { |p| command.match?(p) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def dangerous_command?(command)
|
|
154
|
+
dangerous_patterns = [
|
|
155
|
+
%r{\brm\s+-rf\s+[/~]},
|
|
156
|
+
/\bmkfs\b/,
|
|
157
|
+
%r{\bdd\s+.*of=/dev},
|
|
158
|
+
%r{>\s*/dev/sd},
|
|
159
|
+
/\bshutdown\b/,
|
|
160
|
+
/\breboot\b/,
|
|
161
|
+
/\binit\s+0\b/,
|
|
162
|
+
/:(){ :|:& };:/
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
dangerous_patterns.any? { |pattern| command.match?(pattern) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def format_result(stdout, stderr, status)
|
|
169
|
+
output_parts = []
|
|
170
|
+
|
|
171
|
+
output_parts << if status.success?
|
|
172
|
+
"Exit code: 0"
|
|
173
|
+
else
|
|
174
|
+
"Exit code: #{status.exitstatus}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
output_parts << "STDOUT:\n#{truncate_output(stdout)}" if stdout && !stdout.empty?
|
|
178
|
+
|
|
179
|
+
output_parts << "STDERR:\n#{truncate_output(stderr)}" if stderr && !stderr.empty?
|
|
180
|
+
|
|
181
|
+
output_parts << "(no output)" if stdout.to_s.empty? && stderr.to_s.empty?
|
|
182
|
+
|
|
183
|
+
output_parts.join("\n\n")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def truncate_output(output)
|
|
187
|
+
return output if output.length <= MAX_OUTPUT_SIZE
|
|
188
|
+
|
|
189
|
+
half = MAX_OUTPUT_SIZE / 2
|
|
190
|
+
"#{output[0...half]}\n\n... [truncated #{output.length - MAX_OUTPUT_SIZE} characters] ...\n\n#{output[-half..]}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
module Tools
|
|
5
|
+
# Manage background jobs
|
|
6
|
+
class Jobs < Base
|
|
7
|
+
def name
|
|
8
|
+
"jobs"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"Manage background jobs. List running/completed jobs, get output, or kill jobs."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parameters
|
|
16
|
+
{
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
action: {
|
|
20
|
+
type: "string",
|
|
21
|
+
enum: %w[list status output kill],
|
|
22
|
+
description: "Action: list (all jobs), status (job details), output (job logs), kill (stop job)"
|
|
23
|
+
},
|
|
24
|
+
job_id: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Job ID (required for status, output, kill)"
|
|
27
|
+
},
|
|
28
|
+
lines: {
|
|
29
|
+
type: "integer",
|
|
30
|
+
description: "Number of output lines to show (default: 50)"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
required: ["action"]
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute(action:, job_id: nil, lines: 50)
|
|
38
|
+
case action
|
|
39
|
+
when "list"
|
|
40
|
+
list_jobs
|
|
41
|
+
when "status"
|
|
42
|
+
get_status(job_id)
|
|
43
|
+
when "output"
|
|
44
|
+
get_output(job_id, lines)
|
|
45
|
+
when "kill"
|
|
46
|
+
kill_job(job_id)
|
|
47
|
+
else
|
|
48
|
+
error("Unknown action: #{action}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def job_manager
|
|
55
|
+
# Use memory_dir for job storage (falls back to workspace if not set)
|
|
56
|
+
storage_dir = @context[:memory_dir] || workspace
|
|
57
|
+
@job_manager ||= BackgroundJobManager.new(workspace: storage_dir)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def list_jobs
|
|
61
|
+
jobs = job_manager.list
|
|
62
|
+
|
|
63
|
+
return "No background jobs found." if jobs.empty?
|
|
64
|
+
|
|
65
|
+
output = ["Background Jobs:\n"]
|
|
66
|
+
|
|
67
|
+
running = jobs.select { |j| j[:running] }
|
|
68
|
+
completed = jobs.reject { |j| j[:running] }
|
|
69
|
+
|
|
70
|
+
if running.any?
|
|
71
|
+
output << "RUNNING:"
|
|
72
|
+
running.each do |job|
|
|
73
|
+
output << " [#{job[:job_id]}] PID #{job[:pid]} - #{job[:name]}"
|
|
74
|
+
end
|
|
75
|
+
output << ""
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if completed.any?
|
|
79
|
+
output << "COMPLETED:"
|
|
80
|
+
completed.first(10).each do |job|
|
|
81
|
+
output << " [#{job[:job_id]}] - #{job[:name]}"
|
|
82
|
+
end
|
|
83
|
+
output << " ... and #{completed.length - 10} more" if completed.length > 10
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
output.join("\n")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def get_status(job_id)
|
|
90
|
+
return error("Job ID required") unless job_id
|
|
91
|
+
|
|
92
|
+
status = job_manager.status(job_id)
|
|
93
|
+
return error("Job not found: #{job_id}") unless status
|
|
94
|
+
|
|
95
|
+
<<~STATUS
|
|
96
|
+
Job: #{status[:job_id]}
|
|
97
|
+
Name: #{status[:name]}
|
|
98
|
+
Status: #{status[:running] ? "RUNNING" : "COMPLETED"}
|
|
99
|
+
PID: #{status[:pid]}
|
|
100
|
+
Command: #{status[:command]}
|
|
101
|
+
|
|
102
|
+
Recent output:
|
|
103
|
+
#{status[:output].lines.last(20).join}
|
|
104
|
+
STATUS
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def get_output(job_id, lines)
|
|
108
|
+
return error("Job ID required") unless job_id
|
|
109
|
+
|
|
110
|
+
output = job_manager.output(job_id, lines: lines)
|
|
111
|
+
return error("Job not found: #{job_id}") unless output
|
|
112
|
+
|
|
113
|
+
"Output (last #{lines} lines):\n#{output}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def kill_job(job_id)
|
|
117
|
+
return error("Job ID required") unless job_id
|
|
118
|
+
|
|
119
|
+
if job_manager.kill(job_id)
|
|
120
|
+
success("Killed job: #{job_id}")
|
|
121
|
+
else
|
|
122
|
+
error("Could not kill job: #{job_id} (may not be running)")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|