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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +456 -0
  5. data/exe/pocketrb +6 -0
  6. data/lib/pocketrb/agent/compaction.rb +187 -0
  7. data/lib/pocketrb/agent/context.rb +171 -0
  8. data/lib/pocketrb/agent/loop.rb +276 -0
  9. data/lib/pocketrb/agent/spawn_tool.rb +72 -0
  10. data/lib/pocketrb/agent/subagent_manager.rb +196 -0
  11. data/lib/pocketrb/bus/events.rb +99 -0
  12. data/lib/pocketrb/bus/message_bus.rb +148 -0
  13. data/lib/pocketrb/channels/base.rb +69 -0
  14. data/lib/pocketrb/channels/cli.rb +109 -0
  15. data/lib/pocketrb/channels/telegram.rb +607 -0
  16. data/lib/pocketrb/channels/whatsapp.rb +242 -0
  17. data/lib/pocketrb/cli/base.rb +119 -0
  18. data/lib/pocketrb/cli/chat.rb +67 -0
  19. data/lib/pocketrb/cli/config.rb +52 -0
  20. data/lib/pocketrb/cli/cron.rb +144 -0
  21. data/lib/pocketrb/cli/gateway.rb +132 -0
  22. data/lib/pocketrb/cli/init.rb +39 -0
  23. data/lib/pocketrb/cli/plans.rb +28 -0
  24. data/lib/pocketrb/cli/skills.rb +34 -0
  25. data/lib/pocketrb/cli/start.rb +55 -0
  26. data/lib/pocketrb/cli/telegram.rb +93 -0
  27. data/lib/pocketrb/cli/version.rb +18 -0
  28. data/lib/pocketrb/cli/whatsapp.rb +60 -0
  29. data/lib/pocketrb/cli.rb +124 -0
  30. data/lib/pocketrb/config.rb +190 -0
  31. data/lib/pocketrb/cron/job.rb +155 -0
  32. data/lib/pocketrb/cron/service.rb +395 -0
  33. data/lib/pocketrb/heartbeat/service.rb +175 -0
  34. data/lib/pocketrb/mcp/client.rb +172 -0
  35. data/lib/pocketrb/mcp/memory_tool.rb +133 -0
  36. data/lib/pocketrb/media/processor.rb +258 -0
  37. data/lib/pocketrb/memory.rb +283 -0
  38. data/lib/pocketrb/planning/manager.rb +159 -0
  39. data/lib/pocketrb/planning/plan.rb +223 -0
  40. data/lib/pocketrb/planning/tool.rb +176 -0
  41. data/lib/pocketrb/providers/anthropic.rb +333 -0
  42. data/lib/pocketrb/providers/base.rb +98 -0
  43. data/lib/pocketrb/providers/claude_cli.rb +412 -0
  44. data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
  45. data/lib/pocketrb/providers/openrouter.rb +205 -0
  46. data/lib/pocketrb/providers/registry.rb +59 -0
  47. data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
  48. data/lib/pocketrb/providers/types.rb +111 -0
  49. data/lib/pocketrb/session/manager.rb +192 -0
  50. data/lib/pocketrb/session/session.rb +204 -0
  51. data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
  52. data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
  53. data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
  54. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
  55. data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
  56. data/lib/pocketrb/skills/create_tool.rb +115 -0
  57. data/lib/pocketrb/skills/loader.rb +164 -0
  58. data/lib/pocketrb/skills/modify_tool.rb +123 -0
  59. data/lib/pocketrb/skills/skill.rb +75 -0
  60. data/lib/pocketrb/tools/background_job_manager.rb +261 -0
  61. data/lib/pocketrb/tools/base.rb +118 -0
  62. data/lib/pocketrb/tools/browser.rb +152 -0
  63. data/lib/pocketrb/tools/browser_advanced.rb +470 -0
  64. data/lib/pocketrb/tools/browser_session.rb +167 -0
  65. data/lib/pocketrb/tools/cron.rb +222 -0
  66. data/lib/pocketrb/tools/edit_file.rb +101 -0
  67. data/lib/pocketrb/tools/exec.rb +194 -0
  68. data/lib/pocketrb/tools/jobs.rb +127 -0
  69. data/lib/pocketrb/tools/list_dir.rb +102 -0
  70. data/lib/pocketrb/tools/memory.rb +167 -0
  71. data/lib/pocketrb/tools/message.rb +70 -0
  72. data/lib/pocketrb/tools/para_memory.rb +264 -0
  73. data/lib/pocketrb/tools/read_file.rb +65 -0
  74. data/lib/pocketrb/tools/registry.rb +160 -0
  75. data/lib/pocketrb/tools/send_file.rb +158 -0
  76. data/lib/pocketrb/tools/think.rb +35 -0
  77. data/lib/pocketrb/tools/web_fetch.rb +150 -0
  78. data/lib/pocketrb/tools/web_search.rb +102 -0
  79. data/lib/pocketrb/tools/write_file.rb +55 -0
  80. data/lib/pocketrb/version.rb +5 -0
  81. data/lib/pocketrb.rb +75 -0
  82. data/pocketrb.gemspec +60 -0
  83. 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