pcli-agent 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 (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,376 @@
1
+ """Cross-platform fallback sandbox: process + filesystem containment via a
2
+ cwd jail, a scrubbed environment, resource limits where the OS supports them,
3
+ and a wall-clock timeout as the universal safety net.
4
+
5
+ This backend does NOT provide network isolation on any platform — that
6
+ requires Docker. Guardrails should treat network-sensitive tool calls as
7
+ always-ask when this backend is active.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import os
14
+ import subprocess
15
+ import time
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from pcli.sandbox.base import (
21
+ ExecRequest,
22
+ ExecResult,
23
+ Sandbox,
24
+ SandboxCapabilities,
25
+ SandboxSecurityError,
26
+ )
27
+ from pcli.sandbox.limits import is_posix, kill_process_tree, make_posix_preexec_fn
28
+
29
+ _MAX_BACKGROUND_JOB_LIFETIME_S = 2 * 60 * 60
30
+ _BACKGROUND_DRAIN_CHUNK_BYTES = 4096
31
+
32
+ _ENV_ALLOW_NAMES = {
33
+ "PATH",
34
+ "HOME",
35
+ "USERPROFILE",
36
+ "SYSTEMROOT",
37
+ "SYSTEMDRIVE",
38
+ "TEMP",
39
+ "TMP",
40
+ "LANG",
41
+ "LC_ALL",
42
+ "PATHEXT",
43
+ "COMSPEC",
44
+ "PYTHONIOENCODING",
45
+ }
46
+ _ENV_DENY_SUBSTRINGS = ("_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH")
47
+
48
+
49
+ def _scrub_env(extra: dict[str, str]) -> dict[str, str]:
50
+ env: dict[str, str] = {
51
+ name: value for name, value in os.environ.items() if name.upper() in _ENV_ALLOW_NAMES
52
+ }
53
+ for key, value in extra.items():
54
+ if any(pattern in key.upper() for pattern in _ENV_DENY_SUBSTRINGS):
55
+ continue
56
+ env[key] = value
57
+ return env
58
+
59
+
60
+ def _is_relative_to(path: Path, other: Path) -> bool:
61
+ try:
62
+ path.relative_to(other)
63
+ return True
64
+ except ValueError:
65
+ return False
66
+
67
+
68
+ def _truncate(data: bytes, max_bytes: int) -> str:
69
+ if len(data) <= max_bytes:
70
+ return data.decode(errors="replace")
71
+ return data[:max_bytes].decode(errors="replace") + "\n[...output truncated...]"
72
+
73
+
74
+ async def _pump_stream(stream: asyncio.StreamReader, chunks: list[bytes]) -> None:
75
+ """Reads a stream into `chunks` (owned by the caller) as data arrives,
76
+ rather than accumulating in a local variable the way proc.communicate()
77
+ does — a coroutine cancelled mid-read (our own timeout, or the caller
78
+ cancelling the awaiting task) loses whatever's only in its own locals,
79
+ but `chunks` already holds everything read up to that point. Capping to
80
+ max_output_bytes is left to _truncate() on the joined result, same as
81
+ before this pumped-into-a-list approach replaced proc.communicate()."""
82
+ while True:
83
+ chunk = await stream.read(_BACKGROUND_DRAIN_CHUNK_BYTES)
84
+ if not chunk:
85
+ break
86
+ chunks.append(chunk)
87
+
88
+
89
+ @dataclass
90
+ class BackgroundJob:
91
+ """A command started via RestrictedSubprocessSandbox.start_background:
92
+ runs independently of any single tool call, with its stdout/stderr
93
+ continuously drained into memory (capped at max_output_bytes) so
94
+ read_background can report on it without blocking. stdout_read_offset/
95
+ stderr_read_offset track how much of each stream a caller has already
96
+ consumed (see read_background) — new reads only return what's arrived
97
+ since the last one, rather than re-sending everything or silently
98
+ dropping whatever fell outside a fixed tail window."""
99
+
100
+ id: str
101
+ command: str
102
+ started_at: float
103
+ proc: asyncio.subprocess.Process
104
+ stdout_chunks: list[bytes] = field(default_factory=list)
105
+ stderr_chunks: list[bytes] = field(default_factory=list)
106
+ stdout_bytes: int = 0
107
+ stderr_bytes: int = 0
108
+ stdout_read_offset: int = 0
109
+ stderr_read_offset: int = 0
110
+ exit_code: int | None = None
111
+ finished_at: float | None = None
112
+
113
+ @property
114
+ def running(self) -> bool:
115
+ return self.exit_code is None
116
+
117
+
118
+ class RestrictedSubprocessSandbox(Sandbox):
119
+ name = "subprocess"
120
+
121
+ def __init__(
122
+ self,
123
+ *,
124
+ allowed_roots: list[Path] | None = None,
125
+ max_output_bytes: int = 2_000_000,
126
+ cpu_seconds: int | None = 30,
127
+ memory_bytes: int | None = None,
128
+ ) -> None:
129
+ # memory_bytes defaults to None (no RLIMIT_AS): confirmed real case
130
+ # - RLIMIT_AS bounds *virtual* address space, not actual usage, and
131
+ # Go's runtime (kubectl, terraform, most Go-based CLIs - exactly
132
+ # what toolbox exists to wrap) routinely reserves well past a few
133
+ # hundred MB of virtual space at startup regardless of real RSS, so
134
+ # the old 512MB default made ordinary tool invocations fail
135
+ # outright. RLIMIT_RSS is advisory/unenforced on modern Linux, and
136
+ # RLIMIT_DATA only bounds sbrk allocation, which mmap-based
137
+ # allocators (Go's included) bypass - neither is a real fix. This
138
+ # backend's actual safety net is (and always was, per limits.py's
139
+ # own docstring) the wall-clock timeout + kill_process_tree, the
140
+ # same on every platform; cpu_seconds doesn't share this false-
141
+ # positive problem (a large virtual reservation burns no CPU time)
142
+ # and stays on by default. Both are still constructor overrides -
143
+ # see Settings.sandbox_memory_limit_bytes/sandbox_cpu_limit_s
144
+ # (agent/runtime.py) - for anyone who deliberately wants a memory
145
+ # cap, e.g. on a constrained VM.
146
+ self._allowed_roots = [p.expanduser().resolve() for p in (allowed_roots or [Path.cwd()])]
147
+ self._max_output_bytes = max_output_bytes
148
+ self._cpu_seconds = cpu_seconds
149
+ self._memory_bytes = memory_bytes
150
+ self._background_jobs: dict[str, BackgroundJob] = {}
151
+
152
+ def capabilities(self) -> SandboxCapabilities:
153
+ posix = is_posix()
154
+ return SandboxCapabilities(
155
+ supports_network_isolation=False,
156
+ supports_memory_limit=posix,
157
+ supports_cpu_limit=posix,
158
+ )
159
+
160
+ def _validate_cwd(self, cwd: Path) -> Path:
161
+ resolved = cwd.expanduser().resolve()
162
+ for root in self._allowed_roots:
163
+ if resolved == root or _is_relative_to(resolved, root):
164
+ return resolved
165
+ raise SandboxSecurityError(f"cwd '{resolved}' is outside all allowed roots")
166
+
167
+ def _popen_kwargs(self) -> dict:
168
+ if is_posix():
169
+ return {
170
+ "preexec_fn": make_posix_preexec_fn(
171
+ cpu_seconds=self._cpu_seconds, memory_bytes=self._memory_bytes
172
+ ),
173
+ "start_new_session": True,
174
+ }
175
+ return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
176
+
177
+ async def _start_process(
178
+ self, command: list[str] | str, cwd: Path, env: dict[str, str], *, needs_stdin: bool
179
+ ) -> asyncio.subprocess.Process:
180
+ popen_kwargs = self._popen_kwargs()
181
+ stdin_mode = asyncio.subprocess.PIPE if needs_stdin else asyncio.subprocess.DEVNULL
182
+ if isinstance(command, str):
183
+ return await asyncio.create_subprocess_shell(
184
+ command,
185
+ cwd=str(cwd),
186
+ env=env,
187
+ stdin=stdin_mode,
188
+ stdout=asyncio.subprocess.PIPE,
189
+ stderr=asyncio.subprocess.PIPE,
190
+ **popen_kwargs,
191
+ )
192
+ return await asyncio.create_subprocess_exec(
193
+ *command,
194
+ cwd=str(cwd),
195
+ env=env,
196
+ stdin=stdin_mode,
197
+ stdout=asyncio.subprocess.PIPE,
198
+ stderr=asyncio.subprocess.PIPE,
199
+ **popen_kwargs,
200
+ )
201
+
202
+ async def execute(self, request: ExecRequest) -> ExecResult:
203
+ cwd = self._validate_cwd(request.cwd)
204
+ env = _scrub_env(request.env)
205
+ proc = await self._start_process(
206
+ request.command, cwd, env, needs_stdin=request.stdin is not None
207
+ )
208
+
209
+ timed_out = False
210
+ stdin_bytes = request.stdin.encode() if request.stdin is not None else None
211
+ stdout_chunks: list[bytes] = []
212
+ stderr_chunks: list[bytes] = []
213
+ # Pumped via separate tasks (not proc.communicate()) specifically so
214
+ # a timeout/cancellation below still leaves whatever was already
215
+ # read sitting in stdout_chunks/stderr_chunks — communicate() would
216
+ # discard it all, since its accumulated bytes live in a coroutine
217
+ # local that's thrown away when the coroutine is cancelled.
218
+ stdout_task = asyncio.ensure_future(_pump_stream(proc.stdout, stdout_chunks))
219
+ stderr_task = asyncio.ensure_future(_pump_stream(proc.stderr, stderr_chunks))
220
+ try:
221
+ if stdin_bytes is not None:
222
+ proc.stdin.write(stdin_bytes)
223
+ await proc.stdin.drain()
224
+ if proc.stdin is not None:
225
+ proc.stdin.close()
226
+ await asyncio.wait_for(
227
+ asyncio.gather(stdout_task, stderr_task, proc.wait()),
228
+ timeout=request.timeout_s,
229
+ )
230
+ except TimeoutError:
231
+ timed_out = True
232
+ kill_process_tree(proc.pid)
233
+ stdout_task.cancel()
234
+ stderr_task.cancel()
235
+ try:
236
+ await asyncio.wait_for(proc.wait(), timeout=5)
237
+ except TimeoutError:
238
+ pass
239
+ except asyncio.CancelledError:
240
+ # Distinct from the TimeoutError case above: this fires when the
241
+ # *caller* (e.g. the TUI's Esc+Esc turn cancellation) cancels the
242
+ # awaiting task, not when our own wait_for's timeout expires.
243
+ # Without this, the subprocess is silently orphaned — cancelling
244
+ # the Python await here does nothing to the OS process itself.
245
+ kill_process_tree(proc.pid)
246
+ stdout_task.cancel()
247
+ stderr_task.cancel()
248
+ raise
249
+
250
+ stdout_bytes = b"".join(stdout_chunks)
251
+ stderr_bytes = b"".join(stderr_chunks)
252
+ if timed_out:
253
+ note = b"[pcli] command timed out and was killed"
254
+ stderr_bytes = stderr_bytes + b"\n" + note if stderr_bytes else note
255
+
256
+ return ExecResult(
257
+ stdout=_truncate(stdout_bytes, self._max_output_bytes),
258
+ stderr=_truncate(stderr_bytes, self._max_output_bytes),
259
+ exit_code=proc.returncode if proc.returncode is not None else -1,
260
+ timed_out=timed_out,
261
+ backend_used=self.name,
262
+ )
263
+
264
+ # --- Background jobs: run_shell_background/read_background_output/
265
+ # stop_background_process (tools/builtin/shell_tool.py) sit on top of
266
+ # these. Deliberately not part of the Sandbox ABC (see plan) - kept here
267
+ # so DockerSandbox/NullSandbox don't need to implement something they
268
+ # can't meaningfully support yet; callers duck-type-check
269
+ # isinstance(sandbox, RestrictedSubprocessSandbox).
270
+
271
+ async def _pump_background_stream(
272
+ self, stream: asyncio.StreamReader, job: BackgroundJob, attr: str
273
+ ) -> None:
274
+ chunks: list[bytes] = getattr(job, f"{attr}_chunks")
275
+ while True:
276
+ chunk = await stream.read(_BACKGROUND_DRAIN_CHUNK_BYTES)
277
+ if not chunk:
278
+ break
279
+ current = getattr(job, f"{attr}_bytes")
280
+ if current < self._max_output_bytes:
281
+ chunks.append(chunk[: self._max_output_bytes - current])
282
+ setattr(job, f"{attr}_bytes", current + len(chunk))
283
+
284
+ async def _drain_background(self, job: BackgroundJob) -> None:
285
+ await asyncio.gather(
286
+ self._pump_background_stream(job.proc.stdout, job, "stdout"),
287
+ self._pump_background_stream(job.proc.stderr, job, "stderr"),
288
+ )
289
+ job.exit_code = await job.proc.wait()
290
+ job.finished_at = time.monotonic()
291
+
292
+ def _sweep_background_jobs(self, *, keep_finished: int = 20) -> None:
293
+ now = time.monotonic()
294
+ for job in list(self._background_jobs.values()):
295
+ if job.running and now - job.started_at > _MAX_BACKGROUND_JOB_LIFETIME_S:
296
+ kill_process_tree(job.proc.pid)
297
+ finished = sorted(
298
+ (j for j in self._background_jobs.values() if not j.running),
299
+ key=lambda j: j.finished_at or 0.0,
300
+ )
301
+ while len(self._background_jobs) > keep_finished and finished:
302
+ del self._background_jobs[finished.pop(0).id]
303
+
304
+ async def start_background(
305
+ self,
306
+ *,
307
+ command: list[str] | str,
308
+ cwd: Path,
309
+ env: dict[str, str] | None = None,
310
+ max_jobs: int = 5,
311
+ ) -> str:
312
+ self._sweep_background_jobs()
313
+ running = sum(1 for job in self._background_jobs.values() if job.running)
314
+ if running >= max_jobs:
315
+ raise SandboxSecurityError(
316
+ f"Already {running} background job(s) running (max {max_jobs}) — stop one "
317
+ "with stop_background_process before starting another."
318
+ )
319
+ resolved_cwd = self._validate_cwd(cwd)
320
+ scrubbed_env = _scrub_env(env or {})
321
+ proc = await self._start_process(command, resolved_cwd, scrubbed_env, needs_stdin=False)
322
+ job_id = uuid.uuid4().hex[:8]
323
+ command_str = command if isinstance(command, str) else " ".join(command)
324
+ job = BackgroundJob(id=job_id, command=command_str, started_at=time.monotonic(), proc=proc)
325
+ self._background_jobs[job_id] = job
326
+ asyncio.create_task(self._drain_background(job))
327
+ return job_id
328
+
329
+ def get_background(self, job_id: str) -> BackgroundJob | None:
330
+ return self._background_jobs.get(job_id)
331
+
332
+ def read_background(
333
+ self, job_id: str, *, max_chars: int = 4000, reset: bool = False
334
+ ) -> tuple[BackgroundJob, str, str, bool] | None:
335
+ """Returns (job, new_stdout, new_stderr, has_more): only the text
336
+ that arrived since the caller's last read (or from the start, if
337
+ reset or this is the first read), capped to max_chars per stream
338
+ per call — offset-based rather than tail-based, so nothing in the
339
+ middle is silently dropped between polls on a chatty command; a
340
+ caller wanting more just calls again (has_more says so) instead of
341
+ requesting a bigger max_chars, so one poll can't flood context."""
342
+ job = self._background_jobs.get(job_id)
343
+ if job is None:
344
+ return None
345
+ if reset:
346
+ job.stdout_read_offset = 0
347
+ job.stderr_read_offset = 0
348
+ full_stdout = b"".join(job.stdout_chunks).decode(errors="replace")
349
+ full_stderr = b"".join(job.stderr_chunks).decode(errors="replace")
350
+ new_stdout = full_stdout[job.stdout_read_offset :]
351
+ new_stderr = full_stderr[job.stderr_read_offset :]
352
+ stdout_chunk = new_stdout[:max_chars]
353
+ stderr_chunk = new_stderr[:max_chars]
354
+ job.stdout_read_offset += len(stdout_chunk)
355
+ job.stderr_read_offset += len(stderr_chunk)
356
+ has_more = len(new_stdout) > len(stdout_chunk) or len(new_stderr) > len(stderr_chunk)
357
+ return job, stdout_chunk, stderr_chunk, has_more
358
+
359
+ async def stop_background(self, job_id: str) -> bool:
360
+ job = self._background_jobs.get(job_id)
361
+ if job is None:
362
+ return False
363
+ if job.running:
364
+ kill_process_tree(job.proc.pid)
365
+ try:
366
+ await asyncio.wait_for(job.proc.wait(), timeout=5)
367
+ except TimeoutError:
368
+ pass
369
+ return True
370
+
371
+ async def kill_all_background_jobs(self) -> None:
372
+ """Called from ChatScreen.on_unmount so a background job never
373
+ outlives the TUI process it was started from."""
374
+ for job in list(self._background_jobs.values()):
375
+ if job.running:
376
+ kill_process_tree(job.proc.pid)
File without changes
@@ -0,0 +1,194 @@
1
+ """The long-running loop behind `pcli schedule run` - the third kind of
2
+ daemon alongside `pcli telegram`'s (telegram/daemon.py) and the browser
3
+ session's own lazy lifecycle. Ticks on a plain poll interval (no OS-level
4
+ cron integration, no extra process) - see scheduler/models.py's ScheduleJob
5
+ for what a job stores and scheduler/runner.py's run_task_once for how one
6
+ actually executes, shared with `pcli run`.
7
+
8
+ croniter is imported lazily inside the two functions that need it, not at
9
+ this module's top level, so `import pcli.scheduler.daemon` itself doesn't
10
+ require the `schedule` extra to be installed - only actually computing a
11
+ next-run time does (mirrors browser/session.py's lazy playwright import and
12
+ telegram/sender.py's lazy python-telegram-bot import)."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ from collections.abc import Callable
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+
22
+ from pcli.agent.headless import HeadlessTurnResult
23
+ from pcli.config.settings import Settings
24
+ from pcli.scheduler.models import ScheduleJob
25
+ from pcli.scheduler.runner import ScheduledSessionNotFoundError, run_task_once
26
+ from pcli.scheduler.store import read_schedule, update_job
27
+ from pcli.scheduler.triggers import compute_path_signature, get_git_head_sha
28
+ from pcli.session.store import SessionStore
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ OnTickCallback = Callable[[], None]
33
+ OnJobRunCallback = Callable[[ScheduleJob, "HeadlessTurnResult | None", Exception | None], None]
34
+
35
+
36
+ def compute_next_run(cron: str, *, base: datetime) -> datetime:
37
+ from croniter import croniter
38
+
39
+ return croniter(cron, base).get_next(datetime)
40
+
41
+
42
+ async def run_scheduler_daemon(
43
+ settings: Settings,
44
+ *,
45
+ poll_interval_s: int = 30,
46
+ on_tick: OnTickCallback | None = None,
47
+ on_job_run: OnJobRunCallback | None = None,
48
+ ) -> None:
49
+ """Runs until cancelled (Ctrl+C from `pcli schedule run`, or the caller
50
+ cancelling the task). Each tick re-reads schedule.json fresh (see
51
+ scheduler/store.py's read_schedule), so `pcli schedule add`/`remove`/
52
+ `enable`/`disable` run from another process take effect on the very
53
+ next tick without restarting this daemon. Jobs run sequentially within
54
+ a tick - simple, predictable, and avoids overlapping runs of possibly-
55
+ related scheduled tasks competing for the same sandbox/session."""
56
+ store = SessionStore()
57
+ cwd = Path.cwd()
58
+ while True:
59
+ await _run_due_jobs(settings, store, cwd, on_job_run=on_job_run)
60
+ if on_tick is not None:
61
+ on_tick()
62
+ await asyncio.sleep(poll_interval_s)
63
+
64
+
65
+ def _cron_due(job: ScheduleJob, now: datetime) -> bool:
66
+ if job.next_run_at is None:
67
+ assert job.cron is not None # enforced by ScheduleJob's validator
68
+ job.next_run_at = compute_next_run(job.cron, base=now)
69
+ update_job(job)
70
+ return False
71
+ return job.next_run_at <= now
72
+
73
+
74
+ def _current_event_signature(job: ScheduleJob, cwd: Path) -> str | None:
75
+ if job.trigger == "file_change":
76
+ assert job.watch_path is not None # enforced by ScheduleJob's validator
77
+ return compute_path_signature(Path(job.watch_path))
78
+ assert job.trigger == "git_commit"
79
+ repo = Path(job.watch_git_repo) if job.watch_git_repo else cwd
80
+ return get_git_head_sha(repo, job.watch_git_branch)
81
+
82
+
83
+ def _event_due(job: ScheduleJob, cwd: Path) -> bool:
84
+ signature = _current_event_signature(job, cwd)
85
+ if signature is None:
86
+ # A git lookup failed this poll (repo not there yet, git missing,
87
+ # ...) - already logged in triggers.py. Just skip and retry next
88
+ # tick rather than marking the job an error.
89
+ return False
90
+ if job.last_seen_state is None:
91
+ # First time this job's been seen by any poll - baseline without
92
+ # firing, the same "compute and skip" first-tick behavior cron
93
+ # jobs get above, so adding a job against an already-existing
94
+ # file/commit doesn't fire immediately.
95
+ job.last_seen_state = signature
96
+ update_job(job)
97
+ return False
98
+ return signature != job.last_seen_state
99
+
100
+
101
+ def _rebaseline_event_job(job: ScheduleJob, cwd: Path) -> None:
102
+ """Re-reads the current signature *after* the job's own run finished
103
+ and stores that as the new baseline - deliberately not the signature
104
+ that triggered the run. Otherwise a task that itself edits the watched
105
+ path, or commits to the watched branch (a very plausible case - e.g. a
106
+ task that auto-commits its own changes), would see its own output as
107
+ "one more change" on the very next poll and fire again indefinitely.
108
+ Baselining after the run means only changes introduced *after* the
109
+ job's own run completed count as the next trigger."""
110
+ signature = _current_event_signature(job, cwd)
111
+ if signature is not None:
112
+ job.last_seen_state = signature
113
+ update_job(job)
114
+
115
+
116
+ async def _run_due_jobs(
117
+ settings: Settings,
118
+ store: SessionStore,
119
+ cwd: Path,
120
+ *,
121
+ on_job_run: OnJobRunCallback | None,
122
+ ) -> None:
123
+ now = datetime.now(UTC)
124
+ for job in read_schedule().jobs:
125
+ if not job.enabled:
126
+ continue
127
+ is_due = _cron_due(job, now) if job.trigger == "cron" else _event_due(job, cwd)
128
+ if not is_due:
129
+ continue
130
+ await _run_one_job(job, settings, store, cwd, on_job_run=on_job_run)
131
+ if job.trigger != "cron":
132
+ _rebaseline_event_job(job, cwd)
133
+
134
+
135
+ async def _run_one_job(
136
+ job: ScheduleJob,
137
+ settings: Settings,
138
+ store: SessionStore,
139
+ cwd: Path,
140
+ *,
141
+ on_job_run: OnJobRunCallback | None,
142
+ ) -> None:
143
+ task = job.task
144
+ if job.task_file:
145
+ task = Path(job.task_file).read_text(encoding="utf-8")
146
+ assert task is not None # ScheduleJob guarantees exactly one of task/task_file is set
147
+
148
+ job_settings = settings
149
+ if job.max_cost_usd is not None:
150
+ # A local override for just this job's own runs, same reasoning as
151
+ # `pcli run --max-cost` (cli.py's run_command) - never mutates the
152
+ # shared settings object other jobs/the daemon loop itself use.
153
+ job_settings = settings.model_copy(update={"max_session_cost_usd": job.max_cost_usd})
154
+ if job.audit:
155
+ job_settings = job_settings.model_copy(update={"audit_mode_enabled": True})
156
+
157
+ result: HeadlessTurnResult | None = None
158
+ error: Exception | None = None
159
+ try:
160
+ result = await run_task_once(
161
+ task,
162
+ settings=job_settings,
163
+ store=store,
164
+ cwd=cwd,
165
+ session_id=job.session_id,
166
+ quiet=job.quiet,
167
+ headed=job.headed,
168
+ notify_telegram_flag=job.notify_telegram,
169
+ )
170
+ job.last_status = "ok"
171
+ job.last_error = None
172
+ except ScheduledSessionNotFoundError as exc:
173
+ error = exc
174
+ job.last_status = "error"
175
+ job.last_error = f"session '{job.session_id}' no longer exists"
176
+ logger.error(
177
+ "Scheduled job %s (%s): %s", job.id, job.name or job.cron or job.trigger, job.last_error
178
+ )
179
+ except Exception as exc: # one job failing must not kill the daemon loop
180
+ error = exc
181
+ job.last_status = "error"
182
+ job.last_error = str(exc)
183
+ logger.exception(
184
+ "Scheduled job %s (%s) failed", job.id, job.name or job.cron or job.trigger
185
+ )
186
+
187
+ job.last_run_at = datetime.now(UTC)
188
+ if job.trigger == "cron":
189
+ assert job.cron is not None # enforced by ScheduleJob's validator
190
+ job.next_run_at = compute_next_run(job.cron, base=job.last_run_at)
191
+ update_job(job)
192
+
193
+ if on_job_run is not None:
194
+ on_job_run(job, result, error)
@@ -0,0 +1,97 @@
1
+ """Data model for `pcli schedule`'s crontab-like recurring task scheduling -
2
+ see scheduler/store.py for persistence, scheduler/runner.py for how a job
3
+ actually executes (shared with `pcli run`), and scheduler/daemon.py for the
4
+ loop that decides when a job is due."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import UTC, datetime
9
+ from typing import Literal
10
+
11
+ from pydantic import BaseModel, Field, model_validator
12
+
13
+ from pcli.util.ids import new_id
14
+
15
+
16
+ def _utcnow() -> datetime:
17
+ return datetime.now(UTC)
18
+
19
+
20
+ class ScheduleJob(BaseModel):
21
+ id: str = Field(default_factory=lambda: new_id("job_"))
22
+ name: str = ""
23
+ trigger: Literal["cron", "file_change", "git_commit"] = "cron"
24
+ """Defaults to "cron" so every job already persisted in an existing
25
+ schedule.json (written before this field existed) still validates and
26
+ behaves exactly as before - see the model_validator below for what
27
+ each trigger kind requires."""
28
+ cron: str | None = None
29
+ """Standard 5-field cron expression (minute hour day month weekday),
30
+ e.g. "*/15 * * * *". Required (and validated via croniter.is_valid
31
+ before a job is ever saved - see scheduler/store.py's add_job) when
32
+ trigger == "cron"; otherwise unused."""
33
+ watch_path: str | None = None
34
+ """trigger == "file_change" only: a file or directory (watched
35
+ recursively) whose contents/mtimes are polled for changes - see
36
+ scheduler/triggers.py's compute_path_signature."""
37
+ watch_git_repo: str | None = None
38
+ """trigger == "git_commit" only: path to the repo to watch, defaulting
39
+ to the daemon's own cwd if unset."""
40
+ watch_git_branch: str | None = None
41
+ """trigger == "git_commit" only: branch to watch, defaulting to
42
+ whatever's currently checked out (bare HEAD) if unset."""
43
+ last_seen_state: str | None = None
44
+ """trigger in ("file_change", "git_commit") only: the signature/commit
45
+ SHA last observed by the daemon (scheduler/triggers.py), used to
46
+ detect a change on the next poll. None means "never polled yet" - the
47
+ daemon baselines it on first sight without firing, the same way a
48
+ cron job's unset next_run_at is computed-then-skipped on first sight
49
+ rather than firing immediately."""
50
+ task: str | None = None
51
+ task_file: str | None = None
52
+ """Exactly one of task/task_file is set - mirrors `pcli run`'s own
53
+ --task/--task-file mutual exclusivity."""
54
+ session_id: str | None = None
55
+ """Append to this one continuing session on every run, or None for a
56
+ fresh session each time - mirrors `pcli run --session`."""
57
+ headed: bool = False
58
+ notify_telegram: bool = False
59
+ quiet: bool = True
60
+ max_cost_usd: float | None = None
61
+ """Per-job hard spend cap (USD), overriding Settings.max_session_cost_usd
62
+ for just this job's own runs - mirrors `pcli run --max-cost`, applied the
63
+ same way (scheduler/daemon.py's _run_one_job): never persisted to
64
+ config.toml, never affects other jobs or the TUI. None (the default)
65
+ means this job falls back to whatever max_session_cost_usd is already
66
+ configured (unset by default - no cap)."""
67
+ audit: bool = False
68
+ """Per-job override of Settings.audit_mode_enabled, applied the same
69
+ way as max_cost_usd above - mirrors `pcli run --audit`. False (the
70
+ default) means this job falls back to whatever audit_mode_enabled is
71
+ already configured (off by default)."""
72
+ enabled: bool = True
73
+ created_at: datetime = Field(default_factory=_utcnow)
74
+ next_run_at: datetime | None = None
75
+ """Computed lazily by the daemon on first seeing a job with this unset
76
+ (or after it fires) via croniter(cron, base=...).get_next(datetime) -
77
+ not computed at job-creation time so a job added while the daemon isn't
78
+ running doesn't carry a stale "next run" from whenever `schedule add`
79
+ happened to execute."""
80
+ last_run_at: datetime | None = None
81
+ last_status: str | None = None
82
+ """"ok" or "error" after the most recent run; None if it has never run."""
83
+ last_error: str | None = None
84
+
85
+ @model_validator(mode="after")
86
+ def _validate_trigger_fields(self) -> ScheduleJob:
87
+ if self.trigger == "cron" and not self.cron:
88
+ raise ValueError("trigger 'cron' requires cron to be set.")
89
+ if self.trigger == "file_change" and not self.watch_path:
90
+ raise ValueError("trigger 'file_change' requires watch_path to be set.")
91
+ # "git_commit" has no required field: watch_git_repo/watch_git_branch
92
+ # both default sensibly (daemon's cwd / whatever's checked out).
93
+ return self
94
+
95
+
96
+ class ScheduleStore(BaseModel):
97
+ jobs: list[ScheduleJob] = Field(default_factory=list)