rlmness-cli 0.2.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 (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/job_runner.py ADDED
@@ -0,0 +1,117 @@
1
+ """Detached shell controller. Descriptors arrive on stdin, never through argv."""
2
+ from __future__ import annotations
3
+
4
+ import codecs
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from pathlib import Path
12
+
13
+
14
+ def main():
15
+ spec = json.loads(sys.stdin.readline())
16
+ meta, log = Path(spec["metadata"]), Path(spec["log"])
17
+ stop = meta.with_suffix(".stop")
18
+ import psutil
19
+ state = dict(spec["state"], controller=os.getpid(),
20
+ controller_started=psutil.Process().create_time(), heartbeat=time.time(),
21
+ pid=None, ended=None,
22
+ exit=None, timed_out=False, stopped=False, interrupted=False,
23
+ total_bytes=0, truncated=False)
24
+ lock = threading.Lock()
25
+
26
+ def save():
27
+ with lock:
28
+ state["heartbeat"] = time.time()
29
+ stage = meta.with_suffix(".tmp")
30
+ stage.write_text(json.dumps(state), encoding="utf-8")
31
+ # Windows refuses the swap while a reader has the file open; that
32
+ # reader is gone in moments, and giving up here would abandon the
33
+ # job's process with its state half written.
34
+ for attempt in range(50):
35
+ try:
36
+ stage.replace(meta)
37
+ return
38
+ except PermissionError:
39
+ if attempt == 49:
40
+ raise
41
+ time.sleep(0.02)
42
+
43
+ proc = None
44
+ try:
45
+ proc = subprocess.Popen(spec["argv"], cwd=spec["cwd"], env=spec["env"],
46
+ stdin=subprocess.PIPE if spec.get("keep_stdin_open") else subprocess.DEVNULL,
47
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
48
+ **({} if os.name == "nt" else {"start_new_session": True}))
49
+ state["pid"] = proc.pid
50
+ save()
51
+
52
+ def drain():
53
+ stored = 0
54
+ capped = False
55
+ decoder = codecs.getincrementaldecoder("utf-8")("replace")
56
+ with log.open("wb") as out:
57
+ while chunk := proc.stdout.read1(8192):
58
+ remaining = 0 if capped else max(0, spec["max_bytes"] - stored)
59
+ shown = chunk[:remaining]
60
+ decoder.decode(shown, final=False)
61
+ out.write(shown)
62
+ stored += len(shown)
63
+ if remaining and len(chunk) >= remaining:
64
+ # The disk prefix must end at a complete Unicode character.
65
+ pending = len(decoder.getstate()[0])
66
+ if pending:
67
+ stored -= pending
68
+ out.truncate(stored)
69
+ capped = True
70
+ out.flush()
71
+ with lock:
72
+ state["total_bytes"] += len(chunk)
73
+ state["truncated"] = state["total_bytes"] > stored
74
+
75
+ reader = threading.Thread(target=drain, daemon=True)
76
+ reader.start()
77
+ deadline = time.monotonic() + spec["timeout"] if spec["timeout"] else None
78
+ while proc.poll() is None:
79
+ expired = deadline is not None and time.monotonic() >= deadline
80
+ owner_gone = False
81
+ if not state["persistent"]:
82
+ try:
83
+ owner = psutil.Process(spec["owner"]["pid"])
84
+ owner_gone = abs(owner.create_time() - spec["owner"]["started"]) > 0.01
85
+ except psutil.NoSuchProcess:
86
+ owner_gone = True
87
+ if stop.exists() or expired or owner_gone:
88
+ state["stopped"] = stop.exists() or owner_gone
89
+ state["interrupted"] = owner_gone
90
+ state["timed_out"] = expired
91
+ if os.name == "nt":
92
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)],
93
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
94
+ creationflags=subprocess.CREATE_NO_WINDOW)
95
+ else:
96
+ import signal
97
+ try:
98
+ os.killpg(proc.pid, signal.SIGKILL)
99
+ except ProcessLookupError:
100
+ pass
101
+ proc.wait()
102
+ break
103
+ save()
104
+ time.sleep(0.1)
105
+ reader.join(timeout=2)
106
+ state["exit"] = proc.returncode
107
+ except BaseException as exc:
108
+ state["error"] = str(exc)
109
+ state["exit"] = -1
110
+ finally:
111
+ state["ended"] = time.time()
112
+ save()
113
+ stop.unlink(missing_ok=True)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
rlmagent/jobs.py ADDED
@@ -0,0 +1,365 @@
1
+ """Shell jobs and services the harness owns, and watches that wait for something to change.
2
+
3
+ A job runs in the background with its output kept in a file, so a cell can start it,
4
+ carry on, and read what is new from a cursor. Jobs belong to the session, not to a
5
+ kernel: a sandbox that restarts or a lost variable does not lose them. A service is a
6
+ job expected to keep running (a dev server); starting one can wait for a line that says
7
+ it is ready.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ import uuid
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from rlmness.environment import execution_environment
25
+
26
+ from rlmagent.subscriptions import Subscriptions
27
+
28
+ MAX_READ = 64 * 1024
29
+ MAX_JOBS = 32
30
+ MAX_LOG = 8 * 1024 * 1024
31
+ POLL = 0.2
32
+
33
+
34
+ @dataclass
35
+ class Job:
36
+ id: str
37
+ command: str
38
+ log: Path
39
+ service: bool = False
40
+ name: str = ""
41
+ started: float = field(default_factory=time.time)
42
+ ended: float | None = None
43
+ exit: int | None = None
44
+ timed_out: bool = False
45
+ stopped: bool = False
46
+ proc: subprocess.Popen | None = None
47
+ persistent: bool = False
48
+ total_bytes: int = 0
49
+ truncated: bool = False
50
+ interrupted: bool = False
51
+
52
+ @property
53
+ def metadata(self):
54
+ return self.log.with_suffix(".json")
55
+
56
+ def refresh(self):
57
+ if not self.metadata.exists():
58
+ return
59
+ try:
60
+ state = json.loads(self.metadata.read_text(encoding="utf-8"))
61
+ except (OSError, ValueError):
62
+ return # caught mid-update; the next refresh reads the new state
63
+ if state.get("ended") is None and time.time() - state.get("heartbeat", 0) > 3:
64
+ import psutil
65
+ try:
66
+ controller = psutil.Process(state["controller"])
67
+ alive = abs(controller.create_time() - state["controller_started"]) < 0.01
68
+ except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError):
69
+ alive = False
70
+ if not alive:
71
+ state.update(ended=time.time(), exit=-1, interrupted=True)
72
+ for key in ("ended", "exit", "timed_out", "stopped", "total_bytes", "truncated",
73
+ "interrupted"):
74
+ if key in state:
75
+ setattr(self, key, state[key])
76
+ if self.proc is not None:
77
+ self.proc.poll()
78
+
79
+ @property
80
+ def done(self) -> bool:
81
+ return self.ended is not None
82
+
83
+ def summary(self) -> dict:
84
+ self.refresh()
85
+ return {"id": self.id, "name": self.name, "command": self.command,
86
+ "service": self.service, "done": self.done, "exit": self.exit,
87
+ "timed_out": self.timed_out, "stopped": self.stopped,
88
+ "seconds": round((self.ended or time.time()) - self.started, 1),
89
+ "bytes": self.log.stat().st_size if self.log.exists() else 0,
90
+ "total_bytes": self.total_bytes, "truncated": self.truncated,
91
+ "interrupted": self.interrupted,
92
+ "persistent": self.persistent}
93
+
94
+
95
+ def _bash() -> str:
96
+ found = shutil.which("bash")
97
+ if found is None and os.name == "nt":
98
+ candidate = Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe"
99
+ if candidate.is_file():
100
+ found = str(candidate)
101
+ if found is None:
102
+ raise OSError("Bash is required for shell jobs")
103
+ return found
104
+
105
+
106
+ class Jobs:
107
+ """The session's background jobs. `approve(command)` raises when a command is refused."""
108
+
109
+ def __init__(self, cwd: str, folder: Path, approve=None, check=None, *, env=None,
110
+ max_log_bytes: int = MAX_LOG, shell_access="host", shell_network=False) -> None:
111
+ self.cwd = cwd
112
+ self.folder = folder
113
+ self.approve = approve # async (command) -> None, raises when refused
114
+ self.check = check # (command) -> reason or None, for policy refusals
115
+ self.jobs: dict[str, Job] = {}
116
+ self.subscriptions = Subscriptions(folder / ".watches", self._watch_snapshot)
117
+ self.child_snapshot = None
118
+ self.env = dict(env or {})
119
+ self.shell_access, self.shell_network = shell_access, shell_network
120
+ self.max_log_bytes = max(4, int(max_log_bytes))
121
+ for path in self.folder.glob("*.json"):
122
+ try:
123
+ state = json.loads(path.read_text(encoding="utf-8"))
124
+ key = state["id"]
125
+ if not re.fullmatch(r"[a-f0-9]{8}", key):
126
+ continue
127
+ job = Job(key, state["command"], self.folder / f"{key}.log",
128
+ state["service"], state["name"], started=state["started"],
129
+ persistent=state.get("persistent", False))
130
+ job.refresh()
131
+ self.jobs[key] = job
132
+ except (OSError, ValueError, KeyError):
133
+ continue
134
+
135
+ def _get(self, job_id: str) -> Job:
136
+ job = self.jobs.get(job_id) or next(
137
+ (j for j in self.jobs.values() if j.name and j.name == job_id), None)
138
+ if job is None:
139
+ raise ValueError(f"no job {job_id!r}; jobs() lists them")
140
+ job.refresh()
141
+ return job
142
+
143
+ async def _launch(self, command: str, timeout: float | None, service: bool, name: str,
144
+ persistent: bool = False, argv=None, env=None) -> Job:
145
+ if self.check is not None and (reason := self.check(command)):
146
+ raise ValueError(reason)
147
+ if self.approve is not None:
148
+ await self.approve(command)
149
+ if sum(not j.summary()["done"] for j in self.jobs.values()) >= MAX_JOBS:
150
+ raise ValueError(f"{MAX_JOBS} jobs are already running; stop some first")
151
+ job_id = uuid.uuid4().hex[:8]
152
+ self.folder.mkdir(parents=True, exist_ok=True)
153
+ job = Job(job_id, command, self.folder / f"{job_id}.log", service, name,
154
+ persistent=persistent)
155
+ job.log.touch()
156
+ isolated = self.shell_access == "workspace"
157
+ target_argv = argv
158
+ if isolated:
159
+ from rlmagent.shell_sandbox import plan
160
+ target_argv = await asyncio.to_thread(plan, self.cwd, command, argv=argv,
161
+ env={**self.env, **(env or {})}, network=self.shell_network)
162
+ else:
163
+ target_argv = argv or [_bash(), "--noprofile", "--norc", "-c", command]
164
+ import psutil
165
+ spec = {"metadata": str(job.metadata.resolve()), "log": str(job.log.resolve()),
166
+ "owner": {"pid": os.getpid(), "started": psutil.Process().create_time()},
167
+ "cwd": self.cwd, "argv": target_argv, "keep_stdin_open": isolated,
168
+ "env": execution_environment({"PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1",
169
+ **self.env, **(env or {})}),
170
+ "timeout": timeout, "max_bytes": self.max_log_bytes,
171
+ "state": {"id": job.id, "command": command, "service": service,
172
+ "name": name, "started": job.started, "persistent": persistent}}
173
+ options = ({"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS}
174
+ if os.name == "nt" else {"start_new_session": True})
175
+ runner = str(Path(__file__).with_name("job_runner.py"))
176
+ job.proc = subprocess.Popen([sys.executable, "-I", runner],
177
+ stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
178
+ env=execution_environment(), **options)
179
+ job.proc.stdin.write((json.dumps(spec) + "\n").encode("utf-8"))
180
+ job.proc.stdin.close()
181
+ self.jobs[job_id] = job
182
+ while not job.metadata.exists():
183
+ if job.proc.poll() is not None:
184
+ raise RuntimeError("job controller failed before accepting the command")
185
+ await asyncio.sleep(0.02)
186
+ return job
187
+
188
+ @staticmethod
189
+ def _kill(job: Job) -> None:
190
+ job.refresh()
191
+ if not job.done:
192
+ job.metadata.with_suffix(".stop").touch()
193
+
194
+ # -- functions agents call ---------------------------------------------
195
+
196
+ async def job_start(self, command: str = "", timeout: float | None = None, name: str = "",
197
+ *, argv=None, env=None) -> dict:
198
+ """Run a shell command in the background; returns {"id", ...}. Read its output with
199
+ job_read(id, cursor) and wait for it with job_wait(id)."""
200
+ if argv is not None:
201
+ if not isinstance(argv, list) or not argv or not all(isinstance(x, str) for x in argv):
202
+ raise ValueError("argv must be a nonempty list of strings")
203
+ command = subprocess.list2cmdline(argv)
204
+ job = await self._launch(str(command), float(timeout) if timeout else None, False, name,
205
+ argv=argv, env=env)
206
+ return job.summary()
207
+
208
+ async def job_read(self, job_id: str, cursor: int = 0) -> dict:
209
+ """Output written since cursor (a byte offset): {"text", "cursor", "done", "exit"}.
210
+ Pass the returned cursor next time to get only what is new."""
211
+ job = self._get(job_id)
212
+ with open(job.log, "rb") as f:
213
+ offset = max(0, min(int(cursor), job.log.stat().st_size))
214
+ f.seek(offset)
215
+ data = f.read(MAX_READ)
216
+ # Keep an incomplete trailing UTF-8 sequence for the next read. Invalid bytes
217
+ # within output remain visible as replacement characters.
218
+ import codecs
219
+ decoder = codecs.getincrementaldecoder("utf-8")("replace")
220
+ text = decoder.decode(data, final=job.done and offset + len(data) == job.log.stat().st_size)
221
+ pending = decoder.getstate()[0]
222
+ used = len(data) - len(pending)
223
+ return {"text": text, "cursor": offset + used,
224
+ "more": offset + used < job.log.stat().st_size, "done": job.done, "exit": job.exit,
225
+ "timed_out": job.timed_out}
226
+
227
+ async def job_wait(self, job_id: str, timeout: float = 300.0) -> dict:
228
+ """Wait until a job ends, at most timeout seconds; returns its summary."""
229
+ job = self._get(job_id)
230
+ deadline = time.monotonic() + max(0, float(timeout))
231
+ while not job.done and time.monotonic() < deadline:
232
+ await asyncio.sleep(POLL)
233
+ job.refresh()
234
+ return job.summary()
235
+
236
+ async def job_stop(self, job_id: str) -> dict:
237
+ """Stop a job or service and everything it started."""
238
+ job = self._get(job_id)
239
+ job.stopped = True
240
+ self._kill(job)
241
+ return await self.job_wait(job.id, 30)
242
+
243
+ async def jobs_list(self) -> list[dict]:
244
+ """Every job and service of this session, running or finished."""
245
+ return [job.summary() for job in self.jobs.values()]
246
+
247
+ async def service_start(self, name: str, command: str, ready: str | None = None,
248
+ wait: float = 60.0, *, persist: bool = False, env=None) -> dict:
249
+ """Start a long-running process such as a dev server, under a name. With ready, a
250
+ regular expression, wait up to wait seconds for a line that matches it."""
251
+ for job in self.jobs.values():
252
+ job.refresh()
253
+ if job.name == name and not job.done:
254
+ raise ValueError(f"a service named {name!r} is up; stop it with job_stop first")
255
+ job = await self._launch(str(command), None, True, str(name), persist, env=env)
256
+ summary = job.summary()
257
+ if ready:
258
+ pattern = re.compile(ready)
259
+ deadline = time.monotonic() + float(wait)
260
+ while time.monotonic() < deadline and not job.done:
261
+ job.refresh()
262
+ if pattern.search(job.log.read_text(encoding="utf-8", errors="replace")):
263
+ summary = job.summary()
264
+ summary["ready"] = True
265
+ return summary
266
+ await asyncio.sleep(POLL)
267
+ summary = job.summary()
268
+ summary["ready"] = False
269
+ return summary
270
+
271
+ async def watch(self, job: str | None = None, cursor: int = 0, path: str | None = None,
272
+ inbox=None, timeout: float = 300.0) -> dict:
273
+ """Wait until something changes: a job writes past cursor or ends (job=ID), a file or
274
+ folder changes (path=...), or a message arrives (inbox). Returns what happened, or
275
+ {"event": "timeout"}."""
276
+ deadline = time.monotonic() + float(timeout)
277
+ target = None
278
+ before = None
279
+ if path is not None:
280
+ target = (Path(self.cwd) / path).resolve()
281
+ before = _stamp(target)
282
+ tracked = self._get(job) if job is not None else None
283
+ while time.monotonic() < deadline:
284
+ if tracked is not None:
285
+ tracked.refresh()
286
+ size = tracked.log.stat().st_size if tracked.log.exists() else 0
287
+ if size > int(cursor):
288
+ return {"event": "output", "job": tracked.id, "start": int(cursor), "end": size}
289
+ if tracked.done:
290
+ return {"event": "ended", "job": tracked.id, "exit": tracked.exit}
291
+ if target is not None:
292
+ now = _stamp(target)
293
+ if now != before:
294
+ changed = sorted(k for k in set(now) | set(before or {})
295
+ if (now or {}).get(k) != (before or {}).get(k))
296
+ return {"event": "path", "paths": changed[:200],
297
+ "truncated": len(changed) > 200}
298
+ if inbox is not None and (messages := inbox()):
299
+ return {"event": "message", "messages": messages}
300
+ await asyncio.sleep(POLL)
301
+ return {"event": "timeout"}
302
+
303
+ def _watch_snapshot(self, kind, target):
304
+ if kind == "child":
305
+ if self.child_snapshot is None:
306
+ raise ValueError("no child watch source is attached")
307
+ return target, self.child_snapshot(target)
308
+ if kind == "job":
309
+ job = self._get(target)
310
+ return job.id, {key: value for key, value in job.summary().items()
311
+ if key in {"bytes", "total_bytes", "done", "exit"}}
312
+ root = Path(self.cwd).resolve()
313
+ absolute = (root / target).resolve()
314
+ if not absolute.is_relative_to(root):
315
+ raise ValueError(f"{target} is outside the workspace")
316
+ storage = str(self.subscriptions.storage.resolve())
317
+ return absolute.relative_to(root).as_posix(), {
318
+ Path(name).relative_to(root).as_posix(): stamp
319
+ for name, stamp in _stamp(absolute, follow_files=False).items() if name != storage
320
+ }
321
+
322
+ async def close(self) -> None:
323
+ await self.subscriptions.close()
324
+ for job in self.jobs.values():
325
+ if not job.persistent:
326
+ self._kill(job)
327
+ for job in self.jobs.values():
328
+ if not job.persistent:
329
+ await self.job_wait(job.id, 30)
330
+
331
+ def functions(self, inbox=None) -> dict:
332
+ async def watch(job=None, cursor=0, path=None, messages=False, timeout=300.0):
333
+ """Wait for a change: job=ID (new output past cursor, or its end), path=PATH (a
334
+ file or folder changing), messages=True (another agent writing to you)."""
335
+ return await self.watch(job, cursor, path, inbox if messages else None, timeout)
336
+
337
+ return {"job_start": self.job_start, "job_read": self.job_read,
338
+ "job_wait": self.job_wait, "job_stop": self.job_stop, "jobs": self.jobs_list,
339
+ "service_start": self.service_start, "watch": watch,
340
+ **self.subscriptions.functions()}
341
+
342
+
343
+ def _stamp(target: Path, *, follow_files=True) -> dict:
344
+ """Modification times under a path (one level of files for a folder, skipping noise)."""
345
+ if target.is_file():
346
+ return {str(target): target.stat().st_mtime_ns}
347
+ if not target.is_dir():
348
+ return {}
349
+ stamps = {}
350
+ for folder, dirs, files in os.walk(target):
351
+ dirs[:] = [d for d in dirs if d not in {".git", "node_modules", ".venv", "__pycache__"}]
352
+ for name in files:
353
+ full = Path(folder) / name
354
+ if not follow_files and full.is_symlink():
355
+ continue
356
+ try:
357
+ stamps[str(full)] = full.stat().st_mtime_ns
358
+ except OSError:
359
+ pass
360
+ if len(stamps) > 5000:
361
+ return stamps
362
+ return stamps
363
+
364
+
365
+ __all__ = ["Job", "Jobs"]