tenseleyflow/loader / b1c0cdf

Browse files

Add stateful bash job control

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b1c0cdf3689c50427f5ce2d304376ab6f82f3d94
Parents
ffef5b5
Tree
975779e

5 changed files

StatusFile+-
M .gitignore 1 0
M src/loader/tools/__init__.py 4 1
M src/loader/tools/base.py 6 2
M src/loader/tools/shell_tools.py 593 80
M tests/test_tools.py 44 0
.gitignoremodified
@@ -57,5 +57,6 @@ docs/
5757
 node_modules/
5858
 CLAUDE.md
5959
 refs/
60
+.refs/
6061
 .loader/
6162
 .docs/
src/loader/tools/__init__.pymodified
@@ -4,7 +4,7 @@ from .base import ConfirmationRequired, Tool, ToolRegistry
44
 from .file_tools import EditTool, GlobTool, PatchTool, ReadTool, WriteTool
55
 from .git_tools import GitTool
66
 from .search_tools import GrepTool
7
-from .shell_tools import BashTool
7
+from .shell_tools import BashJobsTool, BashKillTool, BashTool, BashWaitTool
88
 
99
 __all__ = [
1010
     "Tool",
@@ -17,5 +17,8 @@ __all__ = [
1717
     "GlobTool",
1818
     "GitTool",
1919
     "BashTool",
20
+    "BashJobsTool",
21
+    "BashWaitTool",
22
+    "BashKillTool",
2023
     "GrepTool",
2124
 ]
src/loader/tools/base.pymodified
@@ -192,7 +192,7 @@ def create_default_registry(
192192
         ProjectMemoryWriteTool,
193193
     )
194194
     from .search_tools import GrepTool
195
-    from .shell_tools import BashTool
195
+    from .shell_tools import BashJobManager, BashJobsTool, BashKillTool, BashTool, BashWaitTool
196196
     from .workflow_tools import AskUserQuestionTool, TodoWriteTool
197197
 
198198
     registry = ToolRegistry(workspace_root=workspace_root)
@@ -201,7 +201,11 @@ def create_default_registry(
201201
     registry.register(EditTool())
202202
     registry.register(PatchTool())
203203
     registry.register(GlobTool())
204
-    registry.register(BashTool())
204
+    bash_manager = BashJobManager()
205
+    registry.register(BashTool(manager=bash_manager))
206
+    registry.register(BashJobsTool(bash_manager))
207
+    registry.register(BashWaitTool(bash_manager))
208
+    registry.register(BashKillTool(bash_manager))
205209
     registry.register(GrepTool())
206210
     registry.register(GitTool())
207211
     registry.register(TodoWriteTool())
src/loader/tools/shell_tools.pymodified
@@ -1,21 +1,413 @@
1
-"""Shell command execution tools."""
1
+"""Shell command execution tools with stateful job control."""
2
+
3
+from __future__ import annotations
24
 
35
 import asyncio
4
-import shlex
6
+import contextlib
7
+from dataclasses import dataclass, field
8
+from datetime import UTC, datetime
9
+import os
510
 from pathlib import Path
11
+import shlex
12
+import signal
13
+import subprocess
614
 from typing import Any
715
 
816
 from ..runtime.permissions import PermissionMode
917
 from .base import ConfirmationRequired, Tool, ToolResult
1018
 
1119
 
20
+def _utc_now() -> str:
21
+    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
22
+
23
+
24
+@dataclass(slots=True)
25
+class _OutputBuffer:
26
+    """Bounded text buffer for process output."""
27
+
28
+    limit: int
29
+    chunks: list[str] = field(default_factory=list)
30
+    size: int = 0
31
+    truncated: bool = False
32
+
33
+    def append(self, data: bytes) -> None:
34
+        text = data.decode("utf-8", errors="replace")
35
+        if not text:
36
+            return
37
+        remaining = max(self.limit - self.size, 0)
38
+        if remaining <= 0:
39
+            self.truncated = True
40
+            return
41
+        kept = text[:remaining]
42
+        if kept:
43
+            self.chunks.append(kept)
44
+            self.size += len(kept)
45
+        if len(text) > remaining:
46
+            self.truncated = True
47
+
48
+    def text(self) -> str:
49
+        return "".join(self.chunks)
50
+
51
+
52
+@dataclass(slots=True)
53
+class BashJob:
54
+    """One tracked bash subprocess."""
55
+
56
+    job_id: str
57
+    command: str
58
+    cwd: str | None
59
+    background: bool
60
+    timeout: float | None
61
+    mutability: str
62
+    started_at: str
63
+    pid: int
64
+    process: asyncio.subprocess.Process
65
+    stdout_buffer: _OutputBuffer
66
+    stderr_buffer: _OutputBuffer
67
+    status: str = "running"
68
+    exit_code: int | None = None
69
+    finished_at: str | None = None
70
+    timed_out: bool = False
71
+    interrupted: bool = False
72
+    killed: bool = False
73
+    stdout_task: asyncio.Task[None] | None = None
74
+    stderr_task: asyncio.Task[None] | None = None
75
+    completion_task: asyncio.Task[None] | None = None
76
+
77
+    @property
78
+    def is_running(self) -> bool:
79
+        return self.process.returncode is None
80
+
81
+
82
+class BashJobManager:
83
+    """Track bash subprocesses for the lifetime of one Loader runtime."""
84
+
85
+    def __init__(self, *, output_limit: int = 50_000, recent_limit: int = 25) -> None:
86
+        self.output_limit = output_limit
87
+        self.recent_limit = recent_limit
88
+        self._jobs: dict[str, BashJob] = {}
89
+        self._job_order: list[str] = []
90
+        self._counter = 0
91
+        self._active_foreground_job_id: str | None = None
92
+
93
+    @property
94
+    def active_foreground_job_id(self) -> str | None:
95
+        return self._active_foreground_job_id
96
+
97
+    def list_jobs(self, *, limit: int | None = None) -> list[BashJob]:
98
+        selected: list[BashJob] = []
99
+        for job_id in reversed(self._job_order):
100
+            job = self._jobs[job_id]
101
+            selected.append(job)
102
+            if limit is not None and len(selected) >= limit:
103
+                break
104
+        return selected
105
+
106
+    def get_job(self, job_id: str) -> BashJob | None:
107
+        return self._jobs.get(job_id)
108
+
109
+    async def start(
110
+        self,
111
+        *,
112
+        command: str,
113
+        cwd: str | None,
114
+        timeout: float | None,
115
+        background: bool,
116
+        mutability: str,
117
+    ) -> BashJob:
118
+        resolved_cwd = str(Path(cwd).expanduser().resolve()) if cwd else None
119
+        self._counter += 1
120
+        job_id = f"bash-{self._counter}"
121
+
122
+        popen_kwargs: dict[str, Any] = {
123
+            "stdout": asyncio.subprocess.PIPE,
124
+            "stderr": asyncio.subprocess.PIPE,
125
+            "cwd": resolved_cwd,
126
+        }
127
+        if os.name == "nt":
128
+            popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
129
+        else:
130
+            popen_kwargs["start_new_session"] = True
131
+
132
+        process = await asyncio.create_subprocess_shell(command, **popen_kwargs)
133
+        job = BashJob(
134
+            job_id=job_id,
135
+            command=command,
136
+            cwd=resolved_cwd,
137
+            background=background,
138
+            timeout=timeout,
139
+            mutability=mutability,
140
+            started_at=_utc_now(),
141
+            pid=process.pid,
142
+            process=process,
143
+            stdout_buffer=_OutputBuffer(self.output_limit),
144
+            stderr_buffer=_OutputBuffer(self.output_limit),
145
+        )
146
+        job.stdout_task = asyncio.create_task(self._read_stream(job.stdout_buffer, process.stdout))
147
+        job.stderr_task = asyncio.create_task(self._read_stream(job.stderr_buffer, process.stderr))
148
+        job.completion_task = asyncio.create_task(self._monitor_job(job))
149
+
150
+        self._jobs[job_id] = job
151
+        self._job_order.append(job_id)
152
+        self._trim_completed_jobs()
153
+        if not background:
154
+            self._active_foreground_job_id = job_id
155
+        return job
156
+
157
+    async def wait_for_job(
158
+        self,
159
+        job_id: str,
160
+        *,
161
+        timeout: float | None = None,
162
+    ) -> ToolResult:
163
+        job = self._jobs.get(job_id)
164
+        if job is None:
165
+            return ToolResult(f"Unknown bash job: {job_id}", is_error=True)
166
+        if job.completion_task is None:
167
+            return ToolResult(f"Bash job {job_id} has no completion task", is_error=True)
168
+
169
+        if job.is_running and timeout is not None:
170
+            try:
171
+                await asyncio.wait_for(asyncio.shield(job.completion_task), timeout)
172
+            except TimeoutError:
173
+                return ToolResult(
174
+                    f"Wait timed out after {timeout}s; bash job {job_id} is still running.",
175
+                    is_error=True,
176
+                    metadata=self.metadata_for(job),
177
+                )
178
+        else:
179
+            await asyncio.shield(job.completion_task)
180
+        return self.tool_result_for(job)
181
+
182
+    async def kill_job(
183
+        self,
184
+        job_id: str,
185
+        *,
186
+        force_after_ms: int = 1_000,
187
+        interrupted: bool = False,
188
+    ) -> ToolResult:
189
+        job = self._jobs.get(job_id)
190
+        if job is None:
191
+            return ToolResult(f"Unknown bash job: {job_id}", is_error=True)
192
+        if not job.is_running:
193
+            return ToolResult(
194
+                f"Bash job {job_id} is already {job.status}.",
195
+                metadata=self.metadata_for(job),
196
+            )
197
+
198
+        await self._terminate_job(job, force_after_ms=force_after_ms, interrupted=interrupted)
199
+        if job.completion_task is not None:
200
+            await asyncio.shield(job.completion_task)
201
+        status = "Interrupted" if interrupted else "Stopped"
202
+        summary = f"{status} bash job {job.job_id} (pid {job.pid})."
203
+        output = self._render_combined_output(job)
204
+        if output != "(no output)":
205
+            summary += f"\n\n{output}"
206
+        return ToolResult(summary, metadata=self.metadata_for(job))
207
+
208
+    async def interrupt_active_foreground(self) -> ToolResult | None:
209
+        if self._active_foreground_job_id is None:
210
+            return None
211
+        job = self._jobs.get(self._active_foreground_job_id)
212
+        if job is None or not job.is_running:
213
+            return None
214
+        return await self.kill_job(job.job_id, interrupted=True)
215
+
216
+    def terminate_all_now(self) -> list[str]:
217
+        killed: list[str] = []
218
+        for job in self._jobs.values():
219
+            if not job.is_running:
220
+                continue
221
+            job.killed = True
222
+            job.interrupted = True
223
+            self._send_signal(job.process, signal.SIGTERM)
224
+            self._send_signal(job.process, signal.SIGKILL)
225
+            killed.append(job.job_id)
226
+        self._active_foreground_job_id = None
227
+        return killed
228
+
229
+    def render_jobs(self, *, limit: int = 20) -> tuple[str, dict[str, Any]]:
230
+        jobs = self.list_jobs(limit=limit)
231
+        if not jobs:
232
+            return "No bash jobs tracked for this Loader session.", {"jobs": []}
233
+
234
+        lines = ["Bash jobs:"]
235
+        for job in jobs:
236
+            marker = "bg" if job.background else "fg"
237
+            lines.append(
238
+                f"- {job.job_id} [{job.status}] ({marker}, pid={job.pid}) {job.command}"
239
+            )
240
+        return "\n".join(lines), {
241
+            "jobs": [self.metadata_for(job) for job in jobs],
242
+            "active_foreground_job_id": self._active_foreground_job_id,
243
+        }
244
+
245
+    def metadata_for(self, job: BashJob) -> dict[str, Any]:
246
+        return {
247
+            "job_id": job.job_id,
248
+            "pid": job.pid,
249
+            "command": job.command,
250
+            "cwd": job.cwd,
251
+            "background": job.background,
252
+            "status": job.status,
253
+            "started_at": job.started_at,
254
+            "finished_at": job.finished_at,
255
+            "exit_code": job.exit_code,
256
+            "stdout": job.stdout_buffer.text(),
257
+            "stderr": job.stderr_buffer.text(),
258
+            "stdout_truncated": job.stdout_buffer.truncated,
259
+            "stderr_truncated": job.stderr_buffer.truncated,
260
+            "truncated": job.stdout_buffer.truncated or job.stderr_buffer.truncated,
261
+            "timed_out": job.timed_out,
262
+            "interrupted": job.interrupted,
263
+            "killed": job.killed,
264
+            "running": job.is_running,
265
+            "output_limit": self.output_limit,
266
+        }
267
+
268
+    def tool_result_for(self, job: BashJob) -> ToolResult:
269
+        output = self._render_combined_output(job)
270
+        metadata = self.metadata_for(job)
271
+        if job.timed_out:
272
+            return ToolResult(
273
+                f"Command timed out after {job.timeout}s\n{output}",
274
+                is_error=True,
275
+                metadata=metadata,
276
+            )
277
+        if job.interrupted or job.killed:
278
+            return ToolResult(
279
+                f"Command interrupted\n{output}",
280
+                is_error=True,
281
+                metadata=metadata,
282
+            )
283
+        if (job.exit_code or 0) != 0:
284
+            return ToolResult(
285
+                f"Exit code {job.exit_code}\n{output}",
286
+                is_error=True,
287
+                metadata=metadata,
288
+            )
289
+        return ToolResult(output, metadata=metadata)
290
+
291
+    def launch_result_for(self, job: BashJob) -> ToolResult:
292
+        output = (
293
+            f"Started background bash job {job.job_id} (pid {job.pid}).\n"
294
+            f"Use bash_wait(job_id=\"{job.job_id}\") to wait for completion or "
295
+            f"bash_kill(job_id=\"{job.job_id}\") to stop it."
296
+        )
297
+        return ToolResult(output, metadata=self.metadata_for(job))
298
+
299
+    async def _read_stream(
300
+        self,
301
+        buffer: _OutputBuffer,
302
+        stream: asyncio.StreamReader | None,
303
+    ) -> None:
304
+        if stream is None:
305
+            return
306
+        while True:
307
+            chunk = await stream.read(4096)
308
+            if not chunk:
309
+                break
310
+            buffer.append(chunk)
311
+
312
+    async def _monitor_job(self, job: BashJob) -> None:
313
+        try:
314
+            if job.timeout is None:
315
+                await job.process.wait()
316
+            else:
317
+                try:
318
+                    await asyncio.wait_for(job.process.wait(), timeout=job.timeout)
319
+                except TimeoutError:
320
+                    job.timed_out = True
321
+                    await self._terminate_job(
322
+                        job,
323
+                        force_after_ms=500,
324
+                        interrupted=True,
325
+                    )
326
+                    await job.process.wait()
327
+        finally:
328
+            await asyncio.gather(
329
+                job.stdout_task or asyncio.sleep(0),
330
+                job.stderr_task or asyncio.sleep(0),
331
+            )
332
+            job.exit_code = job.process.returncode
333
+            job.finished_at = _utc_now()
334
+            if job.timed_out:
335
+                job.status = "timed_out"
336
+            elif (job.interrupted or job.killed) and job.background:
337
+                job.status = "killed"
338
+            elif job.interrupted:
339
+                job.status = "interrupted"
340
+            elif (job.exit_code or 0) == 0:
341
+                job.status = "completed"
342
+            else:
343
+                job.status = "failed"
344
+            if self._active_foreground_job_id == job.job_id:
345
+                self._active_foreground_job_id = None
346
+
347
+    async def _terminate_job(
348
+        self,
349
+        job: BashJob,
350
+        *,
351
+        force_after_ms: int,
352
+        interrupted: bool,
353
+    ) -> None:
354
+        if not job.is_running:
355
+            return
356
+        job.interrupted = interrupted
357
+        job.killed = not interrupted
358
+        self._send_signal(job.process, signal.SIGTERM)
359
+        try:
360
+            await asyncio.wait_for(job.process.wait(), timeout=force_after_ms / 1000)
361
+        except TimeoutError:
362
+            self._send_signal(job.process, signal.SIGKILL)
363
+
364
+    def _send_signal(
365
+        self,
366
+        process: asyncio.subprocess.Process,
367
+        sig: int,
368
+    ) -> None:
369
+        if process.returncode is not None:
370
+            return
371
+        with contextlib.suppress(ProcessLookupError):
372
+            if os.name == "nt":
373
+                process.send_signal(sig)
374
+            else:
375
+                os.killpg(process.pid, sig)
376
+
377
+    def _render_combined_output(self, job: BashJob) -> str:
378
+        parts: list[str] = []
379
+        stdout = job.stdout_buffer.text()
380
+        stderr = job.stderr_buffer.text()
381
+        if stdout:
382
+            parts.append(stdout)
383
+        if stderr.strip():
384
+            parts.append(f"[stderr]\n{stderr}")
385
+        output = "\n".join(parts) if parts else "(no output)"
386
+        if job.stdout_buffer.truncated or job.stderr_buffer.truncated:
387
+            output += "\n\n... (output truncated)"
388
+        return output
389
+
390
+    def _trim_completed_jobs(self) -> None:
391
+        if len(self._job_order) <= self.recent_limit:
392
+            return
393
+        overflow = len(self._job_order) - self.recent_limit
394
+        for job_id in list(self._job_order):
395
+            if overflow <= 0:
396
+                break
397
+            job = self._jobs[job_id]
398
+            if job.is_running:
399
+                continue
400
+            self._job_order.remove(job_id)
401
+            self._jobs.pop(job_id, None)
402
+            overflow -= 1
403
+
404
+
12405
 class BashTool(Tool):
13
-    """Execute bash commands."""
406
+    """Execute bash commands and manage their subprocess lifecycle."""
14407
 
15408
     required_permission = PermissionMode.DANGER_FULL_ACCESS
16409
     OUTPUT_LIMIT = 50_000
17410
 
18
-    # Commands that are generally safe (read-only operations)
19411
     SAFE_COMMANDS = {
20412
         "ls", "cat", "head", "tail", "grep", "find", "pwd", "whoami", "date",
21413
         "wc", "sort", "uniq", "diff", "file", "stat", "du", "df",
@@ -24,9 +416,32 @@ class BashTool(Tool):
24416
         "uv --version", "pip list", "pip show",
25417
     }
26418
 
27
-    def __init__(self, timeout: float = 120.0, allowed_commands: list[str] | None = None):
419
+    LONG_RUNNING_HINTS = (
420
+        "python -m http.server",
421
+        "python3 -m http.server",
422
+        "npm run dev",
423
+        "npm run start",
424
+        "npm run serve",
425
+        "npm run watch",
426
+        "pnpm dev",
427
+        "yarn dev",
428
+        "bun run dev",
429
+        "vite",
430
+        "next dev",
431
+        "tail -f",
432
+        "watch ",
433
+        "uvicorn --reload",
434
+    )
435
+
436
+    def __init__(
437
+        self,
438
+        timeout: float = 120.0,
439
+        allowed_commands: list[str] | None = None,
440
+        manager: BashJobManager | None = None,
441
+    ) -> None:
28442
         self.timeout = timeout
29
-        self.allowed_commands = allowed_commands  # None means all allowed
443
+        self.allowed_commands = allowed_commands
444
+        self.manager = manager or BashJobManager(output_limit=self.OUTPUT_LIMIT)
30445
 
31446
     @property
32447
     def name(self) -> str:
@@ -34,7 +449,11 @@ class BashTool(Tool):
34449
 
35450
     @property
36451
     def description(self) -> str:
37
-        return "Execute a bash command and return the output. Use for git, npm, build tools, etc."
452
+        return (
453
+            "Execute a bash command and return the output. For servers, watchers, or "
454
+            "other long-running processes, set background=true and inspect them later "
455
+            "with bash_wait or bash_jobs."
456
+        )
38457
 
39458
     @property
40459
     def parameters(self) -> dict[str, Any]:
@@ -54,6 +473,11 @@ class BashTool(Tool):
54473
                     "description": f"Timeout in seconds (default: {self.timeout})",
55474
                     "default": self.timeout,
56475
                 },
476
+                "background": {
477
+                    "type": "boolean",
478
+                    "description": "Run the command in the background and return immediately",
479
+                    "default": False,
480
+                },
57481
             },
58482
             "required": ["command"],
59483
         }
@@ -63,21 +487,17 @@ class BashTool(Tool):
63487
         return True
64488
 
65489
     def _is_safe_command(self, command: str) -> bool:
66
-        """Check if command is a known safe (read-only) command."""
67490
         cmd = command.strip().lower()
68
-        # Check exact matches and prefix matches
69491
         for safe in self.SAFE_COMMANDS:
70492
             if cmd == safe or cmd.startswith(safe + " "):
71493
                 return True
72494
         return False
73495
 
74496
     def get_required_permission(self, **kwargs: Any) -> PermissionMode:
75
-        """Classify one shell invocation by its mutability."""
76497
         command = str(kwargs.get("command", ""))
77498
         return self.classify_command_permission(command)
78499
 
79500
     def classify_command_permission(self, command: str) -> PermissionMode:
80
-        """Classify a shell command into a runtime permission mode."""
81501
         normalized = command.strip().lower()
82502
         if not normalized:
83503
             return PermissionMode.DANGER_FULL_ACCESS
@@ -108,7 +528,6 @@ class BashTool(Tool):
108528
         if skip_confirmation:
109529
             return
110530
         command = kwargs.get("command", "")
111
-        # Safe commands don't need confirmation
112531
         if self._is_safe_command(command):
113532
             return
114533
         raise ConfirmationRequired(
@@ -118,102 +537,196 @@ class BashTool(Tool):
118537
         )
119538
 
120539
     def _is_command_allowed(self, command: str) -> bool:
121
-        """Check if command is in the allowed list."""
122540
         if self.allowed_commands is None:
123541
             return True
124
-
125
-        # Extract the base command
126542
         try:
127543
             parts = shlex.split(command)
128
-            if not parts:
129
-                return False
130
-            base_cmd = parts[0]
131
-            return base_cmd in self.allowed_commands
132544
         except ValueError:
133545
             return False
546
+        if not parts:
547
+            return False
548
+        return parts[0] in self.allowed_commands
549
+
550
+    def _looks_long_running(self, command: str) -> bool:
551
+        normalized = " ".join(command.lower().split())
552
+        return any(hint in normalized for hint in self.LONG_RUNNING_HINTS)
134553
 
135554
     async def execute(
136555
         self,
137556
         command: str,
138557
         cwd: str | None = None,
139558
         timeout: float | None = None,
559
+        background: bool = False,
140560
         **kwargs: Any,
141561
     ) -> ToolResult:
562
+        del kwargs
142563
         if not self._is_command_allowed(command):
143564
             return ToolResult(
144565
                 f"Command not allowed. Allowed commands: {self.allowed_commands}",
145566
                 is_error=True,
146567
             )
147568
 
148
-        timeout = timeout or self.timeout
149
-        resolved_cwd = None
150
-        if cwd:
151
-            resolved_cwd = str(Path(cwd).expanduser().resolve())
569
+        effective_timeout = self.timeout if timeout is None else timeout
570
+        if not background and self._looks_long_running(command):
571
+            return ToolResult(
572
+                "This command looks long-running and would block Loader in the foreground. "
573
+                "Re-run it with background=true, then use bash_wait or bash_jobs to inspect it.",
574
+                is_error=True,
575
+                metadata={
576
+                    "command": command,
577
+                    "cwd": str(Path(cwd).expanduser().resolve()) if cwd else None,
578
+                    "background": background,
579
+                    "suggest_background": True,
580
+                    "mutability": self.classify_command_permission(command).as_str(),
581
+                },
582
+            )
152583
 
153584
         try:
154
-            process = await asyncio.create_subprocess_shell(
155
-                command,
156
-                stdout=asyncio.subprocess.PIPE,
157
-                stderr=asyncio.subprocess.PIPE,
158
-                cwd=resolved_cwd,
585
+            job = await self.manager.start(
586
+                command=command,
587
+                cwd=cwd,
588
+                timeout=effective_timeout,
589
+                background=background,
590
+                mutability=self.classify_command_permission(command).as_str(),
159591
             )
160
-
592
+            if background:
593
+                return self.manager.launch_result_for(job)
594
+            if job.completion_task is None:
595
+                return ToolResult("Bash job failed to start correctly", is_error=True)
161596
             try:
162
-                stdout, stderr = await asyncio.wait_for(
163
-                    process.communicate(),
164
-                    timeout=timeout,
165
-                )
166
-            except TimeoutError:
167
-                process.kill()
168
-                await process.wait()
169
-                return ToolResult(
170
-                    f"Command timed out after {timeout}s",
171
-                    is_error=True,
172
-                )
597
+                await asyncio.shield(job.completion_task)
598
+            except asyncio.CancelledError:
599
+                await self.manager.kill_job(job.job_id, interrupted=True)
600
+                raise
601
+            return self.manager.tool_result_for(job)
602
+        except asyncio.CancelledError:
603
+            raise
604
+        except Exception as exc:
605
+            resolved_cwd = str(Path(cwd).expanduser().resolve()) if cwd else None
606
+            return ToolResult(
607
+                f"Error executing command: {exc}",
608
+                is_error=True,
609
+                metadata={"command": command, "cwd": resolved_cwd, "background": background},
610
+            )
173611
 
174
-            output_parts = []
175612
 
176
-            if stdout:
177
-                stdout_text = stdout.decode("utf-8", errors="replace")
178
-                output_parts.append(stdout_text)
613
+class BashJobsTool(Tool):
614
+    """List tracked bash jobs for the current Loader runtime."""
179615
 
180
-            if stderr:
181
-                stderr_text = stderr.decode("utf-8", errors="replace")
182
-                if stderr_text.strip():
183
-                    output_parts.append(f"[stderr]\n{stderr_text}")
616
+    required_permission = PermissionMode.READ_ONLY
184617
 
185
-            output = "\n".join(output_parts) if output_parts else "(no output)"
618
+    def __init__(self, manager: BashJobManager) -> None:
619
+        self.manager = manager
186620
 
187
-            # Truncate if too long
188
-            truncated = len(output) > self.OUTPUT_LIMIT
189
-            if truncated:
190
-                output = output[: self.OUTPUT_LIMIT] + "\n\n... (output truncated)"
191
-            else:
192
-                truncated = False
193
-
194
-            metadata = {
195
-                "command": command,
196
-                "cwd": resolved_cwd,
197
-                "exit_code": process.returncode,
198
-                "stdout": stdout.decode("utf-8", errors="replace") if stdout else "",
199
-                "stderr": stderr.decode("utf-8", errors="replace") if stderr else "",
200
-                "mutability": self.classify_command_permission(command).as_str(),
201
-                "truncated": truncated,
202
-                "output_limit": self.OUTPUT_LIMIT,
203
-            }
204
-
205
-            if process.returncode != 0:
206
-                return ToolResult(
207
-                    f"Exit code {process.returncode}\n{output}",
208
-                    is_error=True,
209
-                    metadata=metadata,
210
-                )
621
+    @property
622
+    def name(self) -> str:
623
+        return "bash_jobs"
211624
 
212
-            return ToolResult(output, metadata=metadata)
625
+    @property
626
+    def description(self) -> str:
627
+        return "List active and recent bash jobs for this Loader session."
213628
 
214
-        except Exception as e:
215
-            return ToolResult(
216
-                f"Error executing command: {e}",
217
-                is_error=True,
218
-                metadata={"command": command, "cwd": resolved_cwd},
219
-            )
629
+    @property
630
+    def parameters(self) -> dict[str, Any]:
631
+        return {
632
+            "type": "object",
633
+            "properties": {
634
+                "limit": {
635
+                    "type": "integer",
636
+                    "description": "Maximum number of recent jobs to include",
637
+                    "default": 20,
638
+                },
639
+            },
640
+        }
641
+
642
+    async def execute(self, limit: int = 20, **kwargs: Any) -> ToolResult:
643
+        del kwargs
644
+        output, metadata = self.manager.render_jobs(limit=limit)
645
+        return ToolResult(output, metadata=metadata)
646
+
647
+
648
+class BashWaitTool(Tool):
649
+    """Wait for one tracked background bash job."""
650
+
651
+    required_permission = PermissionMode.READ_ONLY
652
+
653
+    def __init__(self, manager: BashJobManager) -> None:
654
+        self.manager = manager
655
+
656
+    @property
657
+    def name(self) -> str:
658
+        return "bash_wait"
659
+
660
+    @property
661
+    def description(self) -> str:
662
+        return "Wait for a tracked background bash job to finish and return its output."
663
+
664
+    @property
665
+    def parameters(self) -> dict[str, Any]:
666
+        return {
667
+            "type": "object",
668
+            "properties": {
669
+                "job_id": {
670
+                    "type": "string",
671
+                    "description": "Tracked bash job id, for example bash-1",
672
+                },
673
+                "timeout": {
674
+                    "type": "number",
675
+                    "description": "Optional wait timeout in seconds",
676
+                },
677
+            },
678
+            "required": ["job_id"],
679
+        }
680
+
681
+    async def execute(
682
+        self,
683
+        job_id: str,
684
+        timeout: float | None = None,
685
+        **kwargs: Any,
686
+    ) -> ToolResult:
687
+        del kwargs
688
+        return await self.manager.wait_for_job(job_id, timeout=timeout)
689
+
690
+
691
+class BashKillTool(Tool):
692
+    """Stop one tracked bash job."""
693
+
694
+    required_permission = PermissionMode.WORKSPACE_WRITE
695
+
696
+    def __init__(self, manager: BashJobManager) -> None:
697
+        self.manager = manager
698
+
699
+    @property
700
+    def name(self) -> str:
701
+        return "bash_kill"
702
+
703
+    @property
704
+    def description(self) -> str:
705
+        return "Stop a tracked bash job started during this Loader session."
706
+
707
+    @property
708
+    def parameters(self) -> dict[str, Any]:
709
+        return {
710
+            "type": "object",
711
+            "properties": {
712
+                "job_id": {
713
+                    "type": "string",
714
+                    "description": "Tracked bash job id, for example bash-1",
715
+                },
716
+                "force_after_ms": {
717
+                    "type": "integer",
718
+                    "description": "Grace period before force-killing the job process group",
719
+                    "default": 1000,
720
+                },
721
+            },
722
+            "required": ["job_id"],
723
+        }
724
+
725
+    async def execute(
726
+        self,
727
+        job_id: str,
728
+        force_after_ms: int = 1_000,
729
+        **kwargs: Any,
730
+    ) -> ToolResult:
731
+        del kwargs
732
+        return await self.manager.kill_job(job_id, force_after_ms=force_after_ms)
tests/test_tools.pymodified
@@ -1,5 +1,7 @@
11
 """Tests for tool implementations."""
22
 
3
+import asyncio
4
+
35
 import pytest
46
 
57
 from loader.tools import (
@@ -176,6 +178,48 @@ class TestBashTool:
176178
         assert result.is_error
177179
         assert "Exit code 1" in result.output
178180
 
181
+    @pytest.mark.asyncio
182
+    async def test_bash_background_launch_and_wait(self, tool):
183
+        launch = await tool.execute(
184
+            command='python -c "import time; print(\'ready\'); time.sleep(0.1)"',
185
+            background=True,
186
+        )
187
+
188
+        assert not launch.is_error
189
+        job_id = launch.metadata["job_id"]
190
+        assert job_id.startswith("bash-")
191
+
192
+        await asyncio.sleep(0.05)
193
+        wait_result = await tool.manager.wait_for_job(job_id)
194
+
195
+        assert not wait_result.is_error
196
+        assert "ready" in wait_result.output
197
+        assert wait_result.metadata["status"] == "completed"
198
+
199
+    @pytest.mark.asyncio
200
+    async def test_bash_background_job_can_be_killed(self, tool):
201
+        launch = await tool.execute(
202
+            command='python -c "import time; print(\'server\'); time.sleep(30)"',
203
+            background=True,
204
+        )
205
+        job_id = launch.metadata["job_id"]
206
+
207
+        kill_result = await tool.manager.kill_job(job_id)
208
+
209
+        assert not kill_result.is_error
210
+        assert f"bash job {job_id}" in kill_result.output
211
+        assert kill_result.metadata["status"] == "killed"
212
+        assert kill_result.metadata["interrupted"] is False
213
+        assert kill_result.metadata["killed"] is True
214
+
215
+    @pytest.mark.asyncio
216
+    async def test_bash_rejects_long_running_foreground_command(self, tool):
217
+        result = await tool.execute(command="python -m http.server 8000")
218
+
219
+        assert result.is_error
220
+        assert "background=true" in result.output
221
+        assert result.metadata["suggest_background"] is True
222
+
179223
     def test_is_destructive(self, tool):
180224
         assert tool.is_destructive
181225