sliceagent 0.1.0__py3-none-any.whl

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 (71) hide show
  1. sliceagent/__init__.py +3 -0
  2. sliceagent/__main__.py +6 -0
  3. sliceagent/access.py +93 -0
  4. sliceagent/agents.py +173 -0
  5. sliceagent/background_review.py +146 -0
  6. sliceagent/binsniff.py +89 -0
  7. sliceagent/cli.py +890 -0
  8. sliceagent/clock.py +32 -0
  9. sliceagent/code_grep.py +329 -0
  10. sliceagent/code_index.py +417 -0
  11. sliceagent/config.py +240 -0
  12. sliceagent/context_overflow.py +227 -0
  13. sliceagent/envspec.py +129 -0
  14. sliceagent/errors.py +167 -0
  15. sliceagent/events.py +96 -0
  16. sliceagent/finding_types.py +70 -0
  17. sliceagent/flags.py +63 -0
  18. sliceagent/fuzzy.py +135 -0
  19. sliceagent/guardrails.py +438 -0
  20. sliceagent/guidance.py +69 -0
  21. sliceagent/hippocampus.py +581 -0
  22. sliceagent/hooks.py +334 -0
  23. sliceagent/interfaces.py +144 -0
  24. sliceagent/llm.py +695 -0
  25. sliceagent/loop.py +548 -0
  26. sliceagent/mcp_client.py +255 -0
  27. sliceagent/mcp_security.py +77 -0
  28. sliceagent/memory.py +428 -0
  29. sliceagent/metrics.py +103 -0
  30. sliceagent/model_catalog.py +124 -0
  31. sliceagent/monitor.py +615 -0
  32. sliceagent/neocortex.py +436 -0
  33. sliceagent/onboarding.py +323 -0
  34. sliceagent/oracle.py +36 -0
  35. sliceagent/pagetable.py +255 -0
  36. sliceagent/pfc.py +449 -0
  37. sliceagent/plugins.py +127 -0
  38. sliceagent/policy.py +234 -0
  39. sliceagent/procman.py +187 -0
  40. sliceagent/prompt.py +239 -0
  41. sliceagent/records.py +108 -0
  42. sliceagent/recovery.py +119 -0
  43. sliceagent/regions.py +678 -0
  44. sliceagent/registry.py +128 -0
  45. sliceagent/retriever.py +19 -0
  46. sliceagent/safety.py +332 -0
  47. sliceagent/sandbox.py +143 -0
  48. sliceagent/scheduler.py +92 -0
  49. sliceagent/search_index.py +289 -0
  50. sliceagent/seed.py +465 -0
  51. sliceagent/sensory_cortex.py +500 -0
  52. sliceagent/session.py +222 -0
  53. sliceagent/skill_provenance.py +71 -0
  54. sliceagent/skill_usage.py +123 -0
  55. sliceagent/skills.py +209 -0
  56. sliceagent/subagent.py +332 -0
  57. sliceagent/subdir_hints.py +222 -0
  58. sliceagent/swap.py +182 -0
  59. sliceagent/taskstate.py +57 -0
  60. sliceagent/telemetry.py +59 -0
  61. sliceagent/terminal.py +240 -0
  62. sliceagent/text_utils.py +56 -0
  63. sliceagent/tool_summary.py +93 -0
  64. sliceagent/tools.py +1194 -0
  65. sliceagent/tui.py +1377 -0
  66. sliceagent/web.py +354 -0
  67. sliceagent-0.1.0.dist-info/METADATA +262 -0
  68. sliceagent-0.1.0.dist-info/RECORD +71 -0
  69. sliceagent-0.1.0.dist-info/WHEEL +4 -0
  70. sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
  71. sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
sliceagent/policy.py ADDED
@@ -0,0 +1,234 @@
1
+ """Permission policy — authorization for tool calls.
2
+
3
+ An ordered chain of small policies. Each inspects (name, args) and returns a
4
+ ToolDecision to DENY, or None to abstain (defer to the next policy). First denial
5
+ wins; if every policy abstains, the call is allowed. The chain is a plain callable,
6
+ so it drops straight into hooks.PermissionHook(policy) — the loop and the moat are
7
+ untouched.
8
+
9
+ This is AUTHORIZATION (what's allowed at all). It's distinct from SAFE EXECUTION
10
+ (workspace path confinement + the sandbox), which lives in tools.py / sandbox.py.
11
+ Defense in depth: the policy denies catastrophic commands early with a clear reason;
12
+ the ToolHost still confines paths even if a policy abstains.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ import shlex
19
+ from typing import Callable, Optional
20
+
21
+ from .agents import READ_ONLY_TOOLS # single source of truth for the known read-only surface
22
+ from .hooks import ToolDecision
23
+
24
+ ALLOW = ToolDecision(True)
25
+
26
+ WRITE_TOOLS = frozenset(("edit_file", "append_to_file", "str_replace"))
27
+ # every tool that runs a model-authored shell command/body — the catastrophic denylist must gate ALL of
28
+ # them, not just run_command/execute_code (proc_start/terminal_open/terminal_send execute shell verbatim too).
29
+ EXEC_TOOLS = frozenset(("run_command", "execute_code", "proc_start", "terminal_open", "terminal_send"))
30
+
31
+ # Patterns that are almost never legitimate inside a coding-agent workspace.
32
+ # Kept deliberately narrow so normal dev commands (pytest, pip, npm, git add/commit,
33
+ # rm of a workspace file, mkdir, mv) pass untouched.
34
+ # INVARIANT: every pattern is compiled case-INSENSITIVE (the comprehension below). The floor must not be
35
+ # defeated by casing — on a case-insensitive filesystem (macOS default) `SHUTDOWN` / `/ETC/PASSWD` resolve
36
+ # to the real command/path, so a case-sensitive denylist is a genuine bypass. A pattern needing DOTALL
37
+ # carries an inline `(?s)`.
38
+ # SCOPE: this denylist is a BEST-EFFORT defense-in-depth SPEED BUMP, not a complete sandbox. A regex cannot
39
+ # catch every shell encoding (globs, $(...), subshells, var-expansion, exotic wrappers); chasing each is a
40
+ # losing arms race. The BINDING guards are the sandbox (network=none fail-closed, cwd-confine, secret-scrub)
41
+ # and the permission modes (baby-sitter/teenager CONFIRM every command by default). Patterns here catch the
42
+ # obvious/common forms so an unattended (let-it-go) run still has a floor.
43
+ _DANGEROUS_SRC: list[tuple[str, str]] = [
44
+ (r"(?s):\s*\(\s*\)\s*\{.*\|.*&", "fork bomb"),
45
+ (r"\bsudo\b", "privilege escalation (sudo)"),
46
+ (r"\b(shutdown|reboot|halt|poweroff)\b", "system power control"),
47
+ (r"\b(mkfs|wipefs)\b", "filesystem format"),
48
+ (r"\bdd\b[^\n]*\bof=/dev/", "raw write to a device"),
49
+ (r">\s*/dev/(sd|nvme|disk|hd)", "raw write to a device"),
50
+ # any RECURSIVE rm targeting / ~ $HOME /* .. (workspace-relative rm is fine). Catches -rf, split "-r -f",
51
+ # and long-form --recursive — the recursive flag (short -...r... or --recursive) anywhere before the target.
52
+ (r"\brm\b(?=[^|;&\n]*(?:\s-[a-z]*r|\s--recursive))"
53
+ r"[^|;&\n]*\s(?:/|~|\$HOME|/\*|\.\.)(?=[\s/*'\"]|$)", "recursive delete of / ~ or parent"),
54
+ # chmod/chown on / — flags are OPTIONAL (plain `chmod 755 /` / `chown nobody /` are just as catastrophic
55
+ # as the -R forms), and long-form flags (--recursive) count too. The target must be the bare root.
56
+ (r"\bchmod\b\s+(?:-{1,2}[a-z]+\s+)*[0-7]{3,4}\s+/(?:[\s/*'\"]|$)", "chmod on /"),
57
+ (r"\bchown\b\s+[^\n]*\s/(?:[\s/*'\"]|$)", "chown on /"),
58
+ # remote code piped straight into a shell
59
+ (r"\b(curl|wget|fetch)\b[^|\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|python\d?)\b", "remote script piped to a shell"),
60
+ # writes to / reads of sensitive locations
61
+ (r">>?\s*/etc/", "write to /etc"),
62
+ # credential files + their common glob/prefix forms (cat /etc/pass*, /etc/shadow, /etc/sudoers.d, …)
63
+ (r"/etc/(?:passwd|shadow|gshadow|sudoers|pass[\w*]*|shad[\w*]*|sudoer[\w*]*)", "access to system credential files"),
64
+ (r"(\.ssh/|id_rsa|id_ed25519|\.aws/credentials|\.netrc)", "access to private keys/credentials"),
65
+ (r"\bgit\b[^\n]*\bpush\b[^\n]*(--force\b|--force-with-lease\b|\s-f\b)", "force push"),
66
+ ]
67
+ # Compile ALL case-insensitively in one place — uniform, so a future pattern can't silently reintroduce a
68
+ # casing bypass (the round-3 bug: shutdown/mkfs/dd/etc had no IGNORECASE while rm/chmod/curl did).
69
+ _DANGEROUS: list[tuple[re.Pattern, str]] = [(re.compile(p, re.IGNORECASE), why) for p, why in _DANGEROUS_SRC]
70
+
71
+
72
+ # DENY-BY-DEFAULT reader allowlist for readonly/ask modes. Keying on a mutator NAME set let unknown
73
+ # plugin/MCP tools (and even known mutating builtins absent from the small WRITE/EXEC sets — terminal_*,
74
+ # proc_*, world_set, update_plan, …) slip past these safety modes. Allow ONLY known-safe readers; treat
75
+ # everything else (including any unknown tool) as a mutation. ask_user is interactive, not a mutation.
76
+ _READERS = frozenset(READ_ONLY_TOOLS) | {"ask_user"}
77
+
78
+
79
+ def read_only(name: str, args: dict) -> Optional[ToolDecision]:
80
+ """Deny anything that is not a KNOWN read-only tool (deny-by-default — an unknown tool may mutate)."""
81
+ if name not in _READERS:
82
+ return ToolDecision(False, "read-only mode: only read/list/search tools are allowed")
83
+ return None
84
+
85
+
86
+ def ask_mutations(name: str, args: dict) -> Optional[ToolDecision]:
87
+ """Confirm any tool that is not a KNOWN read-only tool (an unknown tool may modify state / run code)."""
88
+ if name not in _READERS:
89
+ return ToolDecision(False, f"{name} is not a known read-only tool (may modify state or run code)",
90
+ ask=True)
91
+ return None
92
+
93
+
94
+ # Provably read-only shell verbs — a `run_command` whose verb is one of these (and which contains no
95
+ # shell metacharacter that could chain/redirect/substitute) reports state without changing it, so teenager
96
+ # mode runs it without a confirm prompt. Deny-by-default: an unknown verb still asks. The catastrophic floor
97
+ # (no_dangerous_commands) runs BEFORE this, so e.g. `cat /etc/passwd` is still denied.
98
+ _RO_VERBS = frozenset((
99
+ "ls", "pwd", "cat", "head", "tail", "wc", "echo", "which", "type", "env", "printenv", "date",
100
+ "whoami", "hostname", "uname", "id", "groups", "tree", "stat", "file", "du", "df", "realpath",
101
+ "dirname", "basename", "grep", "rg", "egrep", "fgrep", "sort", "uniq", "nl", "cut", "column",
102
+ "cksum", "md5", "md5sum", "sha1sum", "sha256sum", "true",
103
+ ))
104
+ # git subcommands with NO mutating form (excludes branch/tag/config/remote/stash/reflog, which can write).
105
+ _RO_GIT_SUB = frozenset((
106
+ "status", "log", "diff", "show", "rev-parse", "ls-files", "ls-tree", "describe", "blame",
107
+ "shortlog", "rev-list", "cat-file", "for-each-ref", "name-rev", "whatchanged", "count-objects",
108
+ "var", "ls-remote", "grep",
109
+ ))
110
+ # find ACTIONS that run/delete (anything else is a read-only traversal)
111
+ _FIND_MUTATORS = frozenset(("-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprintf", "-fprint", "-fls", "-fprint0"))
112
+ _SHELL_META = re.compile(r"[;&|<>`\n]|\$\(|\$\{|\breturn\b")
113
+
114
+
115
+ def _is_readonly_command(cmd: str) -> bool:
116
+ """True only when `cmd` PROVABLY just reads/reports state — a tiny verb allowlist with no shell
117
+ metacharacters. Conservative by design: anything unrecognized returns False (→ still confirmed)."""
118
+ cmd = (cmd or "").strip()
119
+ if not cmd or _SHELL_META.search(cmd):
120
+ return False
121
+ try:
122
+ toks = shlex.split(cmd)
123
+ except ValueError:
124
+ return False
125
+ if not toks:
126
+ return False
127
+ verb = os.path.basename(toks[0])
128
+ if verb == "git":
129
+ sub = next((t for t in toks[1:] if not t.startswith("-")), "")
130
+ return sub in _RO_GIT_SUB
131
+ if verb == "find":
132
+ return not any(t in _FIND_MUTATORS for t in toks)
133
+ return verb in _RO_VERBS
134
+
135
+
136
+ def ask_commands(name: str, args: dict) -> Optional[ToolDecision]:
137
+ """'teenager' middle ground: auto-allow known file EDITS + reads, but confirm anything that RUNS a
138
+ command (EXEC tools) or is an unknown tool that might. Edits flow; running code pauses for a yes —
139
+ EXCEPT a provably read-only `run_command` (git status, ls, cat, …), which reports state without
140
+ changing it and so runs without a prompt (kills the confirm-hang on 'which repo am I in')."""
141
+ if name in _READERS or name in WRITE_TOOLS:
142
+ return None
143
+ if name == "run_command" and _is_readonly_command(str(args.get("command") or "")):
144
+ return None
145
+ return ToolDecision(False, f"{name} runs a command — confirm before it executes", ask=True)
146
+
147
+
148
+ def no_dangerous_commands(name: str, args: dict) -> Optional[ToolDecision]:
149
+ """Deny shell commands (or execute_code bodies) matching a narrow catastrophic list.
150
+ Scanning arbitrary Python is best-effort — it catches obvious run('rm -rf /')-style
151
+ bodies; the sandbox (cwd confine, secret scrub, timeout) is the real guardrail."""
152
+ if name not in EXEC_TOOLS:
153
+ return None
154
+ cmd = str(args.get("command") or args.get("code") or args.get("input") or "") # input = terminal_send line
155
+ for pat, reason in _DANGEROUS:
156
+ if pat.search(cmd):
157
+ return ToolDecision(False, f"blocked dangerous command: {reason}")
158
+ return None
159
+
160
+
161
+ class PolicyChain:
162
+ """An ordered list of `policy(name, args) -> ToolDecision | None`. Callable so it
163
+ plugs directly into hooks.PermissionHook. First denial wins; all-abstain → ALLOW."""
164
+
165
+ def __init__(self, *policies: Callable[[str, dict], Optional[ToolDecision]]):
166
+ self.policies = policies
167
+
168
+ def __call__(self, name: str, args: dict) -> ToolDecision:
169
+ for p in self.policies:
170
+ d = p(name, args)
171
+ if d is not None and (not d.allow or d.ask): # deny OR ask short-circuits
172
+ return d
173
+ return ALLOW
174
+
175
+
176
+ # Three USER-FACING modes, all sharing the catastrophic-command floor (no fully-unrestricted UI mode).
177
+ # `allow`/`readonly` remain as LEGACY/eval escapes, not advertised. Friendly + legacy names both resolve.
178
+ USER_MODES = ("baby-sitter", "teenager", "let-it-go")
179
+ _MODE_ALIASES = {
180
+ "baby-sitter": "babysitter", "babysitter": "babysitter", "baby": "babysitter", "ask": "babysitter",
181
+ "teenager": "teenager", "teen": "teenager",
182
+ "let-it-go": "letitgo", "letitgo": "letitgo", "letgo": "letitgo", "yolo": "letitgo", "guard": "letitgo",
183
+ "allow": "allow", "readonly": "readonly", # legacy escapes
184
+ }
185
+ _MODE_LABELS = {"babysitter": "baby-sitter", "teenager": "teenager", "letitgo": "let-it-go",
186
+ "allow": "allow", "readonly": "readonly"}
187
+ # canonical → True if the mode confirms (needs an interactive resolver; downgrade to let-it-go when headless)
188
+ CONFIRMS = {"babysitter": True, "teenager": True, "letitgo": False, "allow": False, "readonly": False}
189
+
190
+ # Legacy names still RESOLVE (back-compat for old configs/eval) but their connotation differs from the new
191
+ # modes — warn LOUDLY so a name like `guard` can't SILENTLY downgrade safety (it now means let-it-go = auto).
192
+ _LEGACY_WARN = {
193
+ "guard": "AGENT_POLICY=guard is legacy and now maps to 'let-it-go' (auto-runs everything except "
194
+ "catastrophic commands). For confirmations use 'baby-sitter' or 'teenager'.",
195
+ "ask": "AGENT_POLICY=ask is legacy → use 'baby-sitter' (confirm every edit + command).",
196
+ "allow": "AGENT_POLICY=allow is a legacy permissive eval mode (NO catastrophic floor) — for normal use "
197
+ "pick baby-sitter / teenager / let-it-go.",
198
+ "readonly": "AGENT_POLICY=readonly is a legacy mode (no writes/exec).",
199
+ }
200
+
201
+
202
+ def legacy_warning(name: str) -> Optional[str]:
203
+ """A loud deprecation note when a LEGACY mode name is used, so a safety-connoting name like `guard` can't
204
+ silently resolve to a more permissive mode. None for the current friendly names."""
205
+ return _LEGACY_WARN.get((name or "").strip().lower().replace("_", "-").replace(" ", "-"))
206
+
207
+
208
+ def resolve_policy_mode(name: str) -> Optional[str]:
209
+ """Friendly/legacy mode name → canonical key, or None if unrecognized (the caller warns + defaults)."""
210
+ return _MODE_ALIASES.get((name or "").strip().lower().replace("_", "-").replace(" ", "-"))
211
+
212
+
213
+ def policy_label(canonical: str) -> str:
214
+ """Canonical key → friendly display name for the toolbar/help."""
215
+ return _MODE_LABELS.get(canonical, canonical)
216
+
217
+
218
+ def make_policy(mode: str = "teenager") -> PolicyChain:
219
+ """Three modes, ALL with the catastrophic-command floor:
220
+ baby-sitter — confirm every edit + command; teenager — auto edits, confirm commands;
221
+ let-it-go — auto everything except catastrophic. (legacy: allow=permissive, readonly=no writes.)"""
222
+ canonical = resolve_policy_mode(mode)
223
+ if canonical == "babysitter":
224
+ return PolicyChain(no_dangerous_commands, ask_mutations) # catastrophic→deny, every write/exec→ask
225
+ if canonical == "teenager":
226
+ return PolicyChain(no_dangerous_commands, ask_commands) # catastrophic→deny, commands→ask, edits auto
227
+ if canonical == "letitgo":
228
+ return PolicyChain(no_dangerous_commands) # catastrophic→deny, everything else auto
229
+ if canonical == "allow":
230
+ return PolicyChain() # LEGACY: fully permissive (eval)
231
+ if canonical == "readonly":
232
+ return PolicyChain(read_only) # LEGACY: no writes/exec
233
+ # #28: a typo'd mode must NOT silently fall back to a weaker policy than intended.
234
+ raise ValueError(f"unknown policy mode {mode!r} (expected one of {USER_MODES})")
sliceagent/procman.py ADDED
@@ -0,0 +1,187 @@
1
+ """procman — background / long-running processes for the agent (the gap the one-shot
2
+ ``Sandbox.run`` can't fill).
3
+
4
+ ``Sandbox.run`` blocks and returns only on exit, so two whole classes of work are
5
+ inexpressible: (1) "start a server, then probe it" (the server never exits), and (2)
6
+ multi-minute builds that overrun the run timeout and come back as exit 124. ``ProcManager``
7
+ keeps live children in a registry keyed by a short handle (``p1``, ``p2``, …) so the agent
8
+ can start a process, keep it alive across turns, ``poll`` / ``tail`` / ``wait``, then ``kill``.
9
+
10
+ Local subprocess backend (the eval path); cwd-confined and secret-env-scrubbed exactly like
11
+ ``LocalSandbox``. Output streams to a temp LOGFILE (not a pipe) so ``tail``/``wait`` can read it
12
+ AFTER the call returns — a ``Popen`` pipe would deadlock once its OS buffer fills. Children run
13
+ in their own process group (``start_new_session=True``) so ``kill`` takes down the whole tree
14
+ (a server that forks workers, a build that spawns sub-makes). ``PYTHONUNBUFFERED`` is forced so
15
+ Python children flush to the logfile promptly instead of after exit.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import signal
21
+ import subprocess
22
+ import tempfile
23
+
24
+ from .sandbox import _scrub_env
25
+
26
+ _TAIL_CHARS = 4000 # cap a tail read so a chatty process can't flood the slice
27
+
28
+
29
+ class _Proc:
30
+ __slots__ = ("handle", "cmd", "popen", "log_path", "log_fh")
31
+
32
+ def __init__(self, handle: str, cmd: str, popen, log_path: str, log_fh):
33
+ self.handle = handle
34
+ self.cmd = cmd
35
+ self.popen = popen
36
+ self.log_path = log_path
37
+ self.log_fh = log_fh
38
+
39
+
40
+ class ProcManager:
41
+ """Registry of live background processes. Not threadsafe (the agent loop is single-threaded)."""
42
+
43
+ def __init__(self, *, scrub_secrets: bool = True):
44
+ self.scrub_secrets = scrub_secrets
45
+ self._procs: dict[str, _Proc] = {}
46
+ self._n = 0
47
+
48
+ # ── lifecycle ──────────────────────────────────────────────────────────
49
+ def start(self, command: str, *, cwd: str) -> str:
50
+ """Launch `command` in the background; return a handle. Non-blocking."""
51
+ self._n += 1
52
+ handle = f"p{self._n}"
53
+ fd, log_path = tempfile.mkstemp(prefix=f".sliceagent-{handle}-", suffix=".log")
54
+ log_fh = os.fdopen(fd, "wb")
55
+ env = _scrub_env() if self.scrub_secrets else dict(os.environ)
56
+ env["PYTHONUNBUFFERED"] = "1"
57
+ try:
58
+ popen = self._spawn_proc(command, cwd, env, log_fh)
59
+ except BaseException: # spawn failed (bad cwd, exec error, container-seam failure) — release the fd + temp file
60
+ try:
61
+ log_fh.close()
62
+ finally:
63
+ try:
64
+ os.unlink(log_path)
65
+ except OSError:
66
+ pass
67
+ raise
68
+ self._procs[handle] = _Proc(handle, command, popen, log_path, log_fh)
69
+ return handle
70
+
71
+ def _spawn_proc(self, command, cwd, env, log_fh):
72
+ """Launch the background process. OVERRIDABLE SEAM: a container variant relaunches via
73
+ `docker exec` so the process runs INSIDE the task container, streaming to this host logfile."""
74
+ return subprocess.Popen(
75
+ command, shell=True, cwd=cwd, env=env,
76
+ stdin=subprocess.DEVNULL, stdout=log_fh, stderr=subprocess.STDOUT,
77
+ start_new_session=True,
78
+ )
79
+
80
+ def poll(self, handle: str) -> str:
81
+ p = self._get(handle)
82
+ rc = p.popen.poll()
83
+ if rc is not None and p.log_fh is not None: # self-exited proc: release the write fd now (don't leak it to cleanup)
84
+ try:
85
+ p.log_fh.close()
86
+ except Exception: # noqa: BLE001
87
+ pass
88
+ p.log_fh = None
89
+ return "running" if rc is None else f"exited {rc}"
90
+
91
+ def tail(self, handle: str, lines: int = 40) -> str:
92
+ p = self._get(handle)
93
+ body = self._read_log(p, lines)
94
+ return f"[{handle} {self.poll(handle)}]\n{body}"
95
+
96
+ def wait(self, handle: str, timeout: float) -> str:
97
+ p = self._get(handle)
98
+ try:
99
+ rc = p.popen.wait(timeout=timeout)
100
+ status = f"exited {rc}"
101
+ self.poll(handle) # release the write fd on self-exit (mirror poll/kill) — else wait() leaks one fd/proc
102
+ except subprocess.TimeoutExpired:
103
+ status = f"running (still alive after {timeout:g}s)"
104
+ return f"[{handle} {status}]\n{self._read_log(p, 40)}"
105
+
106
+ def kill(self, handle: str) -> str:
107
+ p = self._get(handle)
108
+ if p.popen.poll() is None:
109
+ self._signal_group(p, signal.SIGTERM)
110
+ try:
111
+ p.popen.wait(timeout=3)
112
+ except subprocess.TimeoutExpired:
113
+ self._signal_group(p, signal.SIGKILL)
114
+ try:
115
+ p.popen.wait(timeout=2)
116
+ except subprocess.TimeoutExpired:
117
+ pass
118
+ status = self.poll(handle)
119
+ # Release the open FD now (the process is dead, so nothing more writes the log) — a long session that
120
+ # starts/kills many procs would otherwise leak one fd per cycle, marching toward EMFILE. Keep the
121
+ # registry entry + on-disk log so proc_poll/proc_tail still work after a kill (the confirm-after-kill
122
+ # UX); cleanup() unlinks the temp file at session end.
123
+ if p.log_fh is not None:
124
+ try:
125
+ p.log_fh.close()
126
+ except Exception: # noqa: BLE001
127
+ pass
128
+ p.log_fh = None
129
+ return f"killed {handle} ({status})"
130
+
131
+ def list(self) -> str:
132
+ if not self._procs:
133
+ return "(no background processes)"
134
+ return "\n".join(f"{h}: {self.poll(h)} — {p.cmd}" for h, p in self._procs.items())
135
+
136
+ def cleanup(self) -> None:
137
+ """Kill every live child and remove its logfile. Call at session end; never raises."""
138
+ for h in list(self._procs):
139
+ try:
140
+ self.kill(h)
141
+ except Exception: # noqa: BLE001 — best-effort teardown
142
+ pass
143
+ p = self._procs.pop(h, None)
144
+ if not p:
145
+ continue
146
+ try:
147
+ p.log_fh.close()
148
+ except Exception: # noqa: BLE001
149
+ pass
150
+ try:
151
+ os.unlink(p.log_path)
152
+ except OSError:
153
+ pass
154
+
155
+ # ── internals ──────────────────────────────────────────────────────────
156
+ def _get(self, handle: str) -> _Proc:
157
+ p = self._procs.get(handle)
158
+ if p is None:
159
+ raise ValueError(
160
+ f"unknown process handle {handle!r}. Live: {', '.join(self._procs) or '(none)'}")
161
+ return p
162
+
163
+ @staticmethod
164
+ def _read_log(p: _Proc, lines: int) -> str:
165
+ try:
166
+ p.log_fh.flush()
167
+ except Exception: # noqa: BLE001
168
+ pass
169
+ try:
170
+ with open(p.log_path, "r", encoding="utf-8", errors="replace") as f:
171
+ data = f.read()
172
+ except OSError:
173
+ data = ""
174
+ tail = "\n".join(data.splitlines()[-max(1, lines):])
175
+ if len(tail) > _TAIL_CHARS:
176
+ tail = "…[earlier output elided]…\n" + tail[-_TAIL_CHARS:]
177
+ return tail or "(no output yet)"
178
+
179
+ @staticmethod
180
+ def _signal_group(p: _Proc, sig: int) -> None:
181
+ try:
182
+ os.killpg(os.getpgid(p.popen.pid), sig)
183
+ except (ProcessLookupError, PermissionError, OSError):
184
+ try:
185
+ p.popen.send_signal(sig)
186
+ except OSError:
187
+ pass