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,1197 @@
1
+ """TelegramDaemon: the business logic behind `pcli telegram` - see cli.py's
2
+ run_telegram_daemon for the actual python-telegram-bot wiring, which is
3
+ thin glue around this class's public methods (not itself heavily unit
4
+ tested against a fake PTB - the same division of labor browser/session.py
5
+ draws between its own control flow and Playwright's raw calls).
6
+
7
+ Owns the one ongoing Session for the authorized chat and processes
8
+ incoming text messages one at a time through a queue - mirrors
9
+ ChatScreen's own "don't run two turns at once" discipline
10
+ (_turn_in_progress / queued followups), just as an explicit asyncio.Queue
11
+ instead of a Textual worker loop, so a message that arrives mid-turn waits
12
+ its turn instead of racing the one already running (both would otherwise
13
+ read/mutate the same Session.messages list concurrently). `!`-prefixed
14
+ shell passthrough is the one exception - it never touches the Session at
15
+ all, so it runs immediately instead of queuing (see handle_text).
16
+
17
+ A fresh Session is started each time the daemon starts (same default `pcli
18
+ run` uses with no --session) - restarting `pcli telegram` begins a new
19
+ conversation. /new resets it without restarting the daemon.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import logging
26
+ import secrets
27
+ from collections.abc import Callable
28
+ from dataclasses import replace
29
+ from pathlib import Path
30
+
31
+ from pcli.agent.compaction import maybe_compact
32
+ from pcli.agent.headless import new_headless_session, run_headless_task
33
+ from pcli.agent.runtime import AgentRuntime, make_tool_context
34
+ from pcli.config.settings import Settings, remove_config_keys, update_config_file
35
+ from pcli.cost.context import (
36
+ ContextLimitTable,
37
+ compute_max_response_tokens,
38
+ current_context_usage,
39
+ set_model_context_limit,
40
+ )
41
+ from pcli.cost.tracker import CostTracker
42
+ from pcli.llm.errors import GatewayError
43
+ from pcli.memory.extraction import extract_memory
44
+ from pcli.memory.models import render_memory_list
45
+ from pcli.memory.store import clear_memory, read_memory, remove_entry
46
+ from pcli.permissions.guardrails import update_guardrails_fs_allowed_roots, update_guardrails_limits
47
+ from pcli.permissions.manager import PermissionManager
48
+ from pcli.sandbox.base import SandboxSecurityError
49
+ from pcli.session.models import ToolInvocation
50
+ from pcli.session.store import SessionStore
51
+ from pcli.telegram.permissions import (
52
+ PendingApprovals,
53
+ TelegramSender,
54
+ ask_via_telegram,
55
+ decode_callback_data,
56
+ )
57
+ from pcli.tools.artifacts import SessionArtifactStore
58
+ from pcli.tools.toolbox.manager import ToolboxDiscoveryError
59
+ from pcli.tui.shell_passthrough import run_passthrough_command
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ _MAX_MODEL_BUTTONS = 20
64
+ """Caps the /models inline-button listing the same way /sessions caps its
65
+ own text listing - a long gateway model list would otherwise produce an
66
+ unwieldy wall of one-button-per-row inline buttons."""
67
+
68
+ _HELP_TEXT = (
69
+ "Commands:\n"
70
+ "\n"
71
+ "/help - show this help.\n"
72
+ "/new - start a fresh session (does not restart the daemon).\n"
73
+ "/rename [name] - view or set the current session's title.\n"
74
+ "/timeout [seconds] - view or set the per-request gateway timeout.\n"
75
+ "/temperature [value|off] - view or set the sampling temperature.\n"
76
+ "/budget [amount|off] - view or set a hard cap on this session's spend.\n"
77
+ "/context_limit [tokens] - view or set the assumed context window for the current model.\n"
78
+ "/max_tool_iterations [n] - view or set the per-turn tool-call iteration cap.\n"
79
+ "/artifact_threshold [chars] - view or set the tool-output length archived instead of "
80
+ "kept inline.\n"
81
+ "/max_tool_calls_per_turn [n] - view or set the per-turn tool-call guardrail (0 = "
82
+ "unlimited).\n"
83
+ "/max_tool_calls_per_minute [n] - view or set the per-minute tool-call guardrail (0 = "
84
+ "unlimited).\n"
85
+ "/prune_tool_results [off|on|n] - view, toggle, or set tool-result pruning.\n"
86
+ "/max_response_tokens [off|on|margin] - view, toggle, or set the dynamic max_tokens cap.\n"
87
+ "/allowed_roots [add|remove] [path] - view or edit the filesystem guardrail's allowed "
88
+ "paths.\n"
89
+ "/memory [forget <id>|clear] - view, trim, or clear pcli's cross-session memory of you.\n"
90
+ "/toolbox [discover <name> [path]|list|remove <name>] - discover, list, or remove "
91
+ "toolbox tools (CLI programs/scripts wrapped as callable tools).\n"
92
+ "/compact - manually summarize the conversation so far to free up context space.\n"
93
+ "/models [name] - set the model, or list what's available from the gateway.\n"
94
+ "/sessions [switch <id>] - list other sessions, or switch to one.\n"
95
+ "/export [path] - export the current session and send it as a document.\n"
96
+ "/plan - enter plan mode: only read-only/exploration tools are available.\n"
97
+ "/build - exit plan mode, restoring full tool access.\n"
98
+ "\n"
99
+ "!<command> - run a shell command directly, bypassing the agent (!!<command> hides the "
100
+ "output).\n"
101
+ )
102
+
103
+
104
+ class _CommandError(Exception):
105
+ """Raised by a scalar-setting command's `apply` closure (see
106
+ TelegramDaemon._handle_scalar_setting_command) to report bad input -
107
+ caught there and sent back as the reply, instead of propagating to
108
+ run_forever's generic "something went wrong" handler. Not a GatewayError
109
+ or anything network-related; purely a validation-failure message."""
110
+
111
+
112
+ def _encode_model_callback(token: str) -> str:
113
+ return f"model:{token}"
114
+
115
+
116
+ def _decode_model_callback(data: str) -> str | None:
117
+ """None if data isn't one of ours - mirrors permissions.py's own
118
+ decode_callback_data (a "perm:"-prefixed payload), a separate,
119
+ parallel namespace rather than overloading that one, so
120
+ handle_callback can simply try both."""
121
+ prefix, _, token = data.partition(":")
122
+ if prefix != "model" or not token:
123
+ return None
124
+ return token
125
+
126
+
127
+ class TelegramDaemon:
128
+ def __init__(
129
+ self,
130
+ *,
131
+ settings: Settings,
132
+ runtime: AgentRuntime,
133
+ permission_manager: PermissionManager,
134
+ store: SessionStore,
135
+ cwd: Path,
136
+ sender: TelegramSender,
137
+ ) -> None:
138
+ self._settings = settings
139
+ self._runtime = runtime
140
+ self._permission_manager = permission_manager
141
+ self._store = store
142
+ self._cwd = cwd
143
+ self._sender = sender
144
+ self._pending_approvals = PendingApprovals()
145
+ self._chat_id = settings.telegram_chat_id
146
+ self._session = new_headless_session(store, settings, cwd)
147
+ self._queue: asyncio.Queue[str] = asyncio.Queue()
148
+ self._context_limit_table = ContextLimitTable.load()
149
+ self._turn_in_progress = False
150
+ self._model_choices: dict[str, str] = {}
151
+ self._plan_mode = False
152
+
153
+ @property
154
+ def session_id(self) -> str:
155
+ return self._session.id
156
+
157
+ def _is_authorized(self, chat_id: int) -> bool:
158
+ # Personal automation, not a multi-user bot (see Settings.
159
+ # telegram_chat_id) - anything from another chat is silently
160
+ # ignored, not just refused, so this bot doesn't even confirm to a
161
+ # stranger that it exists and is listening.
162
+ return chat_id == self._chat_id
163
+
164
+ async def handle_text(self, chat_id: int, text: str) -> None:
165
+ """Queues a message for processing - never runs it inline, except
166
+ for `!`-prefixed shell passthrough (see handle_shell_passthrough),
167
+ which bypasses the queue/session/LLM entirely, mirroring the TUI's
168
+ own immediate, turn-independent handling of `!command` in
169
+ ChatScreen.on_chat_input_submitted. See the module docstring for why
170
+ queuing everything else matters."""
171
+ if not self._is_authorized(chat_id):
172
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
173
+ return
174
+ if text.startswith("!"):
175
+ await self.handle_shell_passthrough(chat_id, text)
176
+ return
177
+ await self._queue.put(text)
178
+
179
+ async def handle_shell_passthrough(self, chat_id: int, raw: str) -> None:
180
+ """Mirrors ChatScreen's `!command`/`!!command` (tui/shell_passthrough.py)
181
+ - runs the command directly against the real environment, bypassing
182
+ the LLM, the sandbox, permissions, and session/artifact recording
183
+ entirely. Safe to expose here under the same trust model as the TUI
184
+ version: the chat-id authorization check above is this bot's
185
+ equivalent of "the user's own keyboard" (see the module docstring's
186
+ "personal automation, not a multi-user bot"). The TUI's `!!!`
187
+ (handing off a real interactive terminal) has no Telegram
188
+ equivalent - there's no TTY to hand off to - so it gets an
189
+ explanatory reply instead of being attempted."""
190
+ if not self._is_authorized(chat_id):
191
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
192
+ return
193
+
194
+ if raw.startswith("!!!"):
195
+ await self._sender.send_message(
196
+ chat_id,
197
+ "'!!!' (an interactive terminal) isn't supported over Telegram - there's "
198
+ "no terminal to hand it off to. Use '!command' or '!!command' instead.",
199
+ )
200
+ return
201
+
202
+ quiet = raw.startswith("!!")
203
+ command = raw[2:].strip() if quiet else raw[1:].strip()
204
+ if not command:
205
+ await self._sender.send_message(
206
+ chat_id,
207
+ "Usage: !<command> to run a shell command (!!<command> to hide the result).",
208
+ )
209
+ return
210
+
211
+ result = await run_passthrough_command(command, cwd=self._cwd)
212
+
213
+ if quiet:
214
+ await self._sender.send_message(chat_id, f"$ {command}\n(output hidden)")
215
+ return
216
+
217
+ output = result.stdout
218
+ if result.stderr:
219
+ output += f"\n--- stderr ---\n{result.stderr}"
220
+ footer = f"\n[exit_code={result.exit_code}]"
221
+ if result.timed_out:
222
+ footer += " (timed out)"
223
+ await self._sender.send_message(chat_id, f"$ {command}\n{output}{footer}")
224
+
225
+ async def _handle_scalar_setting_command(
226
+ self,
227
+ chat_id: int,
228
+ arg: str | None,
229
+ *,
230
+ view: Callable[[], str],
231
+ apply: Callable[[str], str],
232
+ ) -> None:
233
+ """Shared by every "/command [value]" setting below (mirrors the
234
+ view-then-set shape of chat.py's equivalents, e.g.
235
+ ChatScreen._handle_timeout_command): no value -> report the current
236
+ one via `view`; a value -> `apply` it (parse, validate, persist,
237
+ return the confirmation text - or raise _CommandError for bad
238
+ input, same as a validation failure chat.py reports as a toast).
239
+ Unlike chat.py, nothing here calls self._agent_loop.set_*() to
240
+ apply a change immediately - TelegramDaemon has no persistent
241
+ AgentLoop; run_headless_task builds a fresh one from self._settings
242
+ on every message, so persisting the setting is already enough.
243
+ Same authorization gate as handle_text."""
244
+ if not self._is_authorized(chat_id):
245
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
246
+ return
247
+ try:
248
+ reply = view() if not arg else apply(arg)
249
+ except _CommandError as exc:
250
+ await self._sender.send_message(chat_id, str(exc))
251
+ return
252
+ await self._sender.send_message(chat_id, reply)
253
+
254
+ async def handle_timeout_command(self, chat_id: int, arg: str | None) -> None:
255
+ def view() -> str:
256
+ current = self._settings.request_timeout_s
257
+ effective = self._settings.effective_request_timeout_s
258
+ note = (
259
+ f" (effective: {effective:g}s - floored for local-api)"
260
+ if effective != current
261
+ else ""
262
+ )
263
+ return f"request_timeout_s is currently {current:g}s{note}. Usage: /timeout <seconds>"
264
+
265
+ def apply(raw: str) -> str:
266
+ try:
267
+ seconds = float(raw)
268
+ except ValueError:
269
+ raise _CommandError(f"'{raw}' isn't a valid number of seconds.") from None
270
+ if seconds <= 0:
271
+ raise _CommandError("request_timeout_s must be greater than 0.")
272
+ self._settings.request_timeout_s = seconds
273
+ update_config_file(request_timeout_s=seconds)
274
+ return f"request_timeout_s set to {seconds:g}s - takes effect on the next gateway request."
275
+
276
+ await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
277
+
278
+ async def handle_temperature_command(self, chat_id: int, arg: str | None) -> None:
279
+ def view() -> str:
280
+ current = self._settings.default_temperature
281
+ text = f"{current:g}" if current is not None else "unset (gateway/model default)"
282
+ return f"default_temperature is currently {text}. Usage: /temperature <value>|off"
283
+
284
+ def apply(raw: str) -> str:
285
+ if raw == "off":
286
+ self._settings.default_temperature = None
287
+ remove_config_keys("default_temperature")
288
+ return "default_temperature cleared - gateway/model default applies."
289
+ try:
290
+ value = float(raw)
291
+ except ValueError:
292
+ raise _CommandError(f"'{raw}' isn't a valid number, or 'off'.") from None
293
+ if value < 0:
294
+ raise _CommandError("Temperature must be 0 or greater.")
295
+ self._settings.default_temperature = value
296
+ update_config_file(default_temperature=value)
297
+ return f"default_temperature set to {value:g} - takes effect on the next turn."
298
+
299
+ await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
300
+
301
+ async def handle_budget_command(self, chat_id: int, arg: str | None) -> None:
302
+ spent = self._session.cost.session_total_usd
303
+
304
+ def view() -> str:
305
+ current = self._settings.max_session_cost_usd
306
+ text = f"${current:.2f}" if current is not None else "unset (no cap)"
307
+ return (
308
+ f"max_session_cost_usd is currently {text} (spent so far: ${spent:.4f}). "
309
+ "Usage: /budget <amount>|off"
310
+ )
311
+
312
+ def apply(raw: str) -> str:
313
+ if raw == "off":
314
+ self._settings.max_session_cost_usd = None
315
+ remove_config_keys("max_session_cost_usd")
316
+ return "max_session_cost_usd cleared - no cap."
317
+ try:
318
+ value = float(raw)
319
+ except ValueError:
320
+ raise _CommandError(f"'{raw}' isn't a valid number, or 'off'.") from None
321
+ if value <= 0:
322
+ raise _CommandError(
323
+ "Budget must be greater than 0 (use /budget off to clear it)."
324
+ )
325
+ self._settings.max_session_cost_usd = value
326
+ update_config_file(max_session_cost_usd=value)
327
+ return (
328
+ f"max_session_cost_usd set to ${value:.2f} (spent so far: ${spent:.4f}) - "
329
+ "takes effect on the next round-trip."
330
+ )
331
+
332
+ await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
333
+
334
+ async def handle_context_limit_command(self, chat_id: int, arg: str | None) -> None:
335
+ model = self._session.model or self._settings.default_model
336
+
337
+ def view() -> str:
338
+ if not model:
339
+ raise _CommandError("No model configured to set a context limit for.")
340
+ current = self._context_limit_table.lookup(model)
341
+ return (
342
+ f"Assumed context limit for '{model}': {current:,} tokens. Usage: "
343
+ "/context_limit <tokens>"
344
+ )
345
+
346
+ def apply(raw: str) -> str:
347
+ if not model:
348
+ raise _CommandError("No model configured to set a context limit for.")
349
+ try:
350
+ limit = int(raw)
351
+ except ValueError:
352
+ raise _CommandError(f"'{raw}' isn't a valid number of tokens.") from None
353
+ if limit <= 0:
354
+ raise _CommandError("Context limit must be greater than 0.")
355
+ set_model_context_limit(model, limit)
356
+ self._context_limit_table = ContextLimitTable.load()
357
+ return f"Context limit for '{model}' set to {limit:,} tokens."
358
+
359
+ await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
360
+
361
+ async def handle_max_tool_iterations_command(self, chat_id: int, arg: str | None) -> None:
362
+ def view() -> str:
363
+ current = self._settings.max_tool_iterations
364
+ note = " (currently uncapped: local-api mode)" if self._settings.is_local_api() else ""
365
+ return f"max_tool_iterations is currently {current}{note}. Usage: /max_tool_iterations <n>"
366
+
367
+ def apply(raw: str) -> str:
368
+ try:
369
+ iterations = int(raw)
370
+ except ValueError:
371
+ raise _CommandError(f"'{raw}' isn't a valid number of iterations.") from None
372
+ if iterations <= 0:
373
+ raise _CommandError("max_tool_iterations must be greater than 0.")
374
+ self._settings.max_tool_iterations = iterations
375
+ update_config_file(max_tool_iterations=iterations)
376
+ note = (
377
+ " This session is in local-api mode, so it stays uncapped until that changes."
378
+ if self._settings.is_local_api()
379
+ else " Takes effect on the next turn."
380
+ )
381
+ return f"max_tool_iterations set to {iterations}.{note}"
382
+
383
+ await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
384
+
385
+ async def handle_artifact_threshold_command(self, chat_id: int, arg: str | None) -> None:
386
+ def view() -> str:
387
+ current = self._settings.artifact_threshold_chars
388
+ return (
389
+ f"artifact_threshold_chars is currently {current:,}. Usage: "
390
+ "/artifact_threshold <chars>"
391
+ )
392
+
393
+ def apply(raw: str) -> str:
394
+ try:
395
+ threshold = int(raw)
396
+ except ValueError:
397
+ raise _CommandError(f"'{raw}' isn't a valid number of characters.") from None
398
+ if threshold <= 0:
399
+ raise _CommandError("artifact_threshold_chars must be greater than 0.")
400
+ self._settings.artifact_threshold_chars = threshold
401
+ update_config_file(artifact_threshold_chars=threshold)
402
+ return (
403
+ f"artifact_threshold_chars set to {threshold:,}. Takes effect on the next "
404
+ "tool result."
405
+ )
406
+
407
+ await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
408
+
409
+ async def _handle_guardrail_rate_limit_command(
410
+ self,
411
+ chat_id: int,
412
+ arg: str | None,
413
+ *,
414
+ attr_name: str,
415
+ config_key: str,
416
+ command_name: str,
417
+ window: str,
418
+ ) -> None:
419
+ """Shared by handle_max_tool_calls_per_turn_command and
420
+ handle_max_tool_calls_per_minute_command - mirrors chat.py's own
421
+ _handle_guardrail_rate_limit_command (both view/set the same-shaped
422
+ guardrails.toml [limits] key, where 0 means unlimited)."""
423
+ guardrails = self._permission_manager.guardrails
424
+
425
+ def view() -> str:
426
+ current = getattr(guardrails, attr_name)
427
+ if self._settings.is_local_api():
428
+ note = " (currently forced unlimited: local-api mode)"
429
+ elif current <= 0:
430
+ note = " (0 = unlimited)"
431
+ else:
432
+ note = ""
433
+ return f"{config_key} is currently {current}{note}. Usage: /{command_name} <n> (0 = unlimited)"
434
+
435
+ def apply(raw: str) -> str:
436
+ try:
437
+ value = int(raw)
438
+ except ValueError:
439
+ raise _CommandError(f"'{raw}' isn't a valid number.") from None
440
+ if value < 0:
441
+ raise _CommandError(f"{config_key} must be 0 or greater (0 means unlimited).")
442
+ update_guardrails_limits(**{config_key: value})
443
+ if self._settings.is_local_api():
444
+ note = " This session is in local-api mode, so it stays unlimited until that changes."
445
+ else:
446
+ setattr(guardrails, attr_name, value)
447
+ note = f" Takes effect on the next {window}."
448
+ return f"{config_key} set to {value}.{note}"
449
+
450
+ await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
451
+
452
+ async def handle_max_tool_calls_per_turn_command(self, chat_id: int, arg: str | None) -> None:
453
+ await self._handle_guardrail_rate_limit_command(
454
+ chat_id,
455
+ arg,
456
+ attr_name="max_tool_calls_per_turn",
457
+ config_key="max_tool_calls_per_turn",
458
+ command_name="max_tool_calls_per_turn",
459
+ window="turn",
460
+ )
461
+
462
+ async def handle_max_tool_calls_per_minute_command(self, chat_id: int, arg: str | None) -> None:
463
+ await self._handle_guardrail_rate_limit_command(
464
+ chat_id,
465
+ arg,
466
+ attr_name="max_tool_calls_per_minute",
467
+ config_key="max_tool_calls_per_minute",
468
+ command_name="max_tool_calls_per_minute",
469
+ window="tool call",
470
+ )
471
+
472
+ async def handle_prune_tool_results_command(self, chat_id: int, arg: str | None) -> None:
473
+ def view() -> str:
474
+ state = "enabled" if self._settings.prune_tool_results_enabled else "disabled"
475
+ return (
476
+ f"prune_tool_results is {state}, keeping the most recent "
477
+ f"{self._settings.prune_tool_results_keep_recent_turns} turn(s) verbatim. "
478
+ "Usage: /prune_tool_results off|on|<n>"
479
+ )
480
+
481
+ def apply(raw: str) -> str:
482
+ if raw == "off":
483
+ self._settings.prune_tool_results_enabled = False
484
+ update_config_file(prune_tool_results_enabled=False)
485
+ return "prune_tool_results disabled."
486
+ if raw == "on":
487
+ self._settings.prune_tool_results_enabled = True
488
+ update_config_file(prune_tool_results_enabled=True)
489
+ return "prune_tool_results enabled."
490
+ try:
491
+ keep_recent_turns = int(raw)
492
+ except ValueError:
493
+ raise _CommandError(f"'{raw}' isn't 'off', 'on', or a valid number.") from None
494
+ if keep_recent_turns <= 0:
495
+ raise _CommandError(
496
+ "keep_recent_turns must be greater than 0 (use /prune_tool_results off "
497
+ "to disable pruning entirely)."
498
+ )
499
+ self._settings.prune_tool_results_enabled = True
500
+ self._settings.prune_tool_results_keep_recent_turns = keep_recent_turns
501
+ update_config_file(
502
+ prune_tool_results_enabled=True,
503
+ prune_tool_results_keep_recent_turns=keep_recent_turns,
504
+ )
505
+ return (
506
+ f"prune_tool_results_keep_recent_turns set to {keep_recent_turns}. Takes "
507
+ "effect on the next turn."
508
+ )
509
+
510
+ await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
511
+
512
+ async def handle_max_response_tokens_command(self, chat_id: int, arg: str | None) -> None:
513
+ def view() -> str:
514
+ state = "enabled" if self._settings.max_response_tokens_enabled else "disabled"
515
+ current = compute_max_response_tokens(
516
+ self._session,
517
+ limit_table=self._context_limit_table,
518
+ safety_margin=self._settings.max_response_tokens_safety_margin,
519
+ )
520
+ current_text = (
521
+ f"{current:,} tokens" if current is not None else "no cap (not yet computable)"
522
+ )
523
+ return (
524
+ f"max_response_tokens is {state}, safety margin "
525
+ f"{self._settings.max_response_tokens_safety_margin:,} tokens. Would "
526
+ f"currently send max_tokens={current_text}. Usage: /max_response_tokens "
527
+ "off|on|<margin>"
528
+ )
529
+
530
+ def apply(raw: str) -> str:
531
+ if raw == "off":
532
+ self._settings.max_response_tokens_enabled = False
533
+ update_config_file(max_response_tokens_enabled=False)
534
+ return "max_response_tokens disabled."
535
+ if raw == "on":
536
+ self._settings.max_response_tokens_enabled = True
537
+ update_config_file(max_response_tokens_enabled=True)
538
+ return "max_response_tokens enabled."
539
+ try:
540
+ safety_margin = int(raw)
541
+ except ValueError:
542
+ raise _CommandError(f"'{raw}' isn't 'off', 'on', or a valid number.") from None
543
+ if safety_margin <= 0:
544
+ raise _CommandError(
545
+ "safety margin must be greater than 0 (use /max_response_tokens off to "
546
+ "disable the cap entirely)."
547
+ )
548
+ self._settings.max_response_tokens_enabled = True
549
+ self._settings.max_response_tokens_safety_margin = safety_margin
550
+ update_config_file(
551
+ max_response_tokens_enabled=True,
552
+ max_response_tokens_safety_margin=safety_margin,
553
+ )
554
+ return (
555
+ f"max_response_tokens_safety_margin set to {safety_margin:,}. Takes effect "
556
+ "on the next turn."
557
+ )
558
+
559
+ await self._handle_scalar_setting_command(chat_id, arg, view=view, apply=apply)
560
+
561
+ async def handle_rename_command(self, chat_id: int, arg: str | None) -> None:
562
+ if not self._is_authorized(chat_id):
563
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
564
+ return
565
+ if not arg:
566
+ await self._sender.send_message(
567
+ chat_id,
568
+ f"Current session title: '{self._session.derive_title()}'. Usage: /rename <name>",
569
+ )
570
+ return
571
+ self._session.title = arg
572
+ self._store.save(self._session)
573
+ await self._sender.send_message(chat_id, f"Session renamed to '{arg}'.")
574
+
575
+ async def handle_allowed_roots_command(self, chat_id: int, rest: str) -> None:
576
+ """Mirrors ChatScreen._handle_allowed_roots_command - view or edit
577
+ the filesystem guardrail's allowed_roots list. Telegram-side name
578
+ is /allowed_roots, not /allowed-roots - Telegram bot commands can't
579
+ contain hyphens (see the scalar-setting commands' own note on this).
580
+ "add"/"remove" are plain arguments, not command names, so they keep
581
+ their TUI spelling."""
582
+ if not self._is_authorized(chat_id):
583
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
584
+ return
585
+ guardrails = self._permission_manager.guardrails
586
+ sub_command, _, arg = rest.partition(" ")
587
+ sub_command = sub_command.strip().lower()
588
+ arg = arg.strip()
589
+
590
+ if not sub_command:
591
+ roots = "\n".join(f"- {root}" for root in guardrails.fs_allowed_roots)
592
+ await self._sender.send_message(
593
+ chat_id,
594
+ f"Current allowed_roots:\n{roots}\n\nUsage: /allowed_roots add <path> | "
595
+ "/allowed_roots remove <path>",
596
+ )
597
+ return
598
+
599
+ if sub_command == "add":
600
+ if not arg:
601
+ await self._sender.send_message(chat_id, "Usage: /allowed_roots add <path>")
602
+ return
603
+ if arg in guardrails.fs_allowed_roots:
604
+ await self._sender.send_message(chat_id, f"'{arg}' is already in allowed_roots.")
605
+ return
606
+ new_roots = [*guardrails.fs_allowed_roots, arg]
607
+ update_guardrails_fs_allowed_roots(new_roots)
608
+ guardrails.fs_allowed_roots = new_roots
609
+ await self._sender.send_message(
610
+ chat_id, f"Added '{arg}' to allowed_roots. Takes effect immediately."
611
+ )
612
+ return
613
+
614
+ if sub_command == "remove":
615
+ if not arg:
616
+ await self._sender.send_message(chat_id, "Usage: /allowed_roots remove <path>")
617
+ return
618
+ if arg not in guardrails.fs_allowed_roots:
619
+ await self._sender.send_message(chat_id, f"'{arg}' isn't in allowed_roots.")
620
+ return
621
+ if len(guardrails.fs_allowed_roots) == 1:
622
+ await self._sender.send_message(
623
+ chat_id,
624
+ "Refusing to remove the last allowed_roots entry - the agent needs at "
625
+ "least one, or every filesystem tool call would be denied.",
626
+ )
627
+ return
628
+ new_roots = [root for root in guardrails.fs_allowed_roots if root != arg]
629
+ update_guardrails_fs_allowed_roots(new_roots)
630
+ guardrails.fs_allowed_roots = new_roots
631
+ await self._sender.send_message(
632
+ chat_id, f"Removed '{arg}' from allowed_roots. Takes effect immediately."
633
+ )
634
+ return
635
+
636
+ await self._sender.send_message(
637
+ chat_id,
638
+ f"Unknown /allowed_roots subcommand: '{sub_command}'. Use /allowed_roots, "
639
+ "/allowed_roots add <path>, or /allowed_roots remove <path>.",
640
+ )
641
+
642
+ async def handle_memory_command(self, chat_id: int, rest: str) -> None:
643
+ """Mirrors ChatScreen._handle_memory_command - view, trim, or clear
644
+ pcli's global, cross-session memory (memory/store.py)."""
645
+ if not self._is_authorized(chat_id):
646
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
647
+ return
648
+ sub_command, _, arg = rest.partition(" ")
649
+ sub_command = sub_command.strip().lower()
650
+ arg = arg.strip()
651
+
652
+ if not sub_command:
653
+ await self._sender.send_message(chat_id, render_memory_list(read_memory().entries))
654
+ return
655
+
656
+ if sub_command == "clear":
657
+ clear_memory()
658
+ await self._sender.send_message(chat_id, "Cleared all memory entries.")
659
+ return
660
+
661
+ if sub_command == "forget":
662
+ if not arg:
663
+ await self._sender.send_message(chat_id, "Usage: /memory forget <id>")
664
+ return
665
+ matches = [e for e in read_memory().entries if e.id.endswith(arg)]
666
+ if not matches:
667
+ await self._sender.send_message(chat_id, f"No memory entry found matching '{arg}'.")
668
+ return
669
+ if len(matches) > 1:
670
+ await self._sender.send_message(
671
+ chat_id, f"'{arg}' matches more than one entry - use a longer id."
672
+ )
673
+ return
674
+ remove_entry(matches[0].id)
675
+ await self._sender.send_message(chat_id, f"Forgot: {matches[0].content}")
676
+ return
677
+
678
+ await self._sender.send_message(
679
+ chat_id,
680
+ f"Unknown /memory subcommand: '{sub_command}'. Use /memory, /memory forget <id>, "
681
+ "or /memory clear.",
682
+ )
683
+
684
+ async def handle_help_command(self, chat_id: int) -> None:
685
+ if not self._is_authorized(chat_id):
686
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
687
+ return
688
+ await self._sender.send_message(chat_id, _HELP_TEXT)
689
+
690
+ async def handle_toolbox_command(self, chat_id: int, rest: str) -> None:
691
+ """Mirrors ChatScreen._handle_toolbox_command/_toolbox_discover/
692
+ _toolbox_list/_toolbox_remove - discover, list, or remove toolbox
693
+ tools (CLI programs/scripts wrapped as callable tools). Unlike
694
+ chat.py, self._runtime.toolbox_manager and self._runtime.
695
+ tool_registry are always present here (AgentRuntime builds both for
696
+ every caller - see agent/runtime.py), so there's no "toolbox isn't
697
+ available" guard to port."""
698
+ if not self._is_authorized(chat_id):
699
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
700
+ return
701
+ sub, _, arg = rest.partition(" ")
702
+ arg = arg.strip()
703
+
704
+ if sub == "discover" and arg:
705
+ name, _, path = arg.partition(" ")
706
+ await self._toolbox_discover(chat_id, name, path.strip() or None)
707
+ elif sub == "list":
708
+ await self._toolbox_list(chat_id)
709
+ elif sub == "remove" and arg:
710
+ await self._toolbox_remove(chat_id, arg)
711
+ else:
712
+ await self._sender.send_message(
713
+ chat_id,
714
+ "Usage: /toolbox discover <name> [path] | /toolbox list | "
715
+ "/toolbox remove <name>",
716
+ )
717
+
718
+ async def _toolbox_discover(self, chat_id: int, name: str, path: str | None) -> None:
719
+ await self._sender.send_message(chat_id, f"Discovering '{name}'...")
720
+ try:
721
+ summary = await self._runtime.toolbox_manager.discover(
722
+ name,
723
+ gateway_client=self._runtime.client,
724
+ model=self._settings.default_model or None,
725
+ path=path,
726
+ )
727
+ except (ToolboxDiscoveryError, GatewayError, SandboxSecurityError) as exc:
728
+ await self._sender.send_message(chat_id, f"Discovery failed: {exc}")
729
+ return
730
+ await self._sender.send_message(chat_id, summary)
731
+
732
+ loaded = await self._runtime.toolbox_manager.load_all()
733
+ self._runtime.tool_registry.merge(loaded)
734
+
735
+ async def _toolbox_list(self, chat_id: int) -> None:
736
+ entries = self._runtime.toolbox_manager.list_discovered()
737
+ if not entries:
738
+ await self._sender.send_message(chat_id, "No software discovered yet. Try /toolbox discover <name>.")
739
+ return
740
+ lines = [
741
+ f"- {name} [{entry['source']}] {entry.get('version', '?')} - "
742
+ f"{entry.get('tool_count', 0)} tool(s)"
743
+ for name, entry in entries.items()
744
+ ]
745
+ await self._sender.send_message(chat_id, "\n".join(lines))
746
+
747
+ async def _toolbox_remove(self, chat_id: int, name: str) -> None:
748
+ self._runtime.toolbox_manager.remove(name)
749
+ await self._sender.send_message(chat_id, f"Removed '{name}' from the toolbox.")
750
+
751
+ def _effective_compaction_model(self) -> str | None:
752
+ """Mirrors ChatScreen._effective_compaction_model - settings.
753
+ compaction_model if configured (a deliberately cheaper/smaller
754
+ model for compaction/memory-extraction's mechanical, lower-stakes
755
+ calls), falling back to default_model/session.model otherwise."""
756
+ return (
757
+ self._settings.compaction_model
758
+ or self._settings.default_model
759
+ or self._session.model
760
+ or None
761
+ )
762
+
763
+ async def handle_compact_command(self, chat_id: int) -> None:
764
+ """Mirrors ChatScreen._run_compaction's "manual" path - unlike
765
+ run_headless_task (which, deliberately narrower than ChatScreen's
766
+ own turn loop, never auto-compacts - see headless.py's module
767
+ docstring), the Telegram daemon has no other way to free up
768
+ context on a long-running conversation, so this is a real need
769
+ here, not just parity for its own sake."""
770
+ if not self._is_authorized(chat_id):
771
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
772
+ return
773
+ if self._turn_in_progress:
774
+ await self._sender.send_message(
775
+ chat_id, "Still working on the current turn - try /compact again once it's done."
776
+ )
777
+ return
778
+
779
+ artifact_store = SessionArtifactStore(self._store, self._session.id)
780
+ cost_tracker = CostTracker(self._session)
781
+ configured_keep_recent_turns = self._settings.auto_compact_keep_recent_turns
782
+ tightened_to: int | None = None
783
+
784
+ try:
785
+ result = await maybe_compact(
786
+ self._session,
787
+ gateway_client=self._runtime.client,
788
+ model=self._effective_compaction_model(),
789
+ artifact_store=artifact_store,
790
+ keep_recent_turns=configured_keep_recent_turns,
791
+ )
792
+ if result is None:
793
+ usage = current_context_usage(self._session, limit_table=self._context_limit_table)
794
+ if usage.fraction >= self._settings.auto_compact_threshold:
795
+ for smaller in range(configured_keep_recent_turns - 1, -1, -1):
796
+ result = await maybe_compact(
797
+ self._session,
798
+ gateway_client=self._runtime.client,
799
+ model=self._effective_compaction_model(),
800
+ artifact_store=artifact_store,
801
+ keep_recent_turns=smaller,
802
+ )
803
+ if result is not None:
804
+ tightened_to = smaller
805
+ break
806
+ except GatewayError as exc:
807
+ logger.exception("Gateway error during compaction: %s", exc.message)
808
+ await self._sender.send_message(chat_id, f"Compaction failed: {exc.message}")
809
+ return
810
+
811
+ if result is None:
812
+ await self._sender.send_message(chat_id, "Nothing to compact yet.")
813
+ return
814
+
815
+ self._session.tool_invocations.append(
816
+ ToolInvocation(
817
+ tool_name="_compaction",
818
+ arguments={},
819
+ status="ok",
820
+ result_summary=f"Compacted {result.messages_compacted} message(s).",
821
+ full_result_ref=SessionArtifactStore.blob_name_for(result.artifact_id),
822
+ )
823
+ )
824
+ cost_tracker.record_turn(
825
+ self._effective_compaction_model() or "", result.usage, source="compaction"
826
+ )
827
+ tightened_note = (
828
+ f" (kept only the last {tightened_to} recent turn(s) verbatim instead of the "
829
+ f"usual {configured_keep_recent_turns} - context was still full at that setting)"
830
+ if tightened_to is not None
831
+ else ""
832
+ )
833
+ await self._sender.send_message(
834
+ chat_id,
835
+ f"Compacted {result.messages_compacted} earlier message(s) to reduce context "
836
+ f"usage (archived as artifact_id='{result.artifact_id}'){tightened_note}.",
837
+ )
838
+ self._store.save(self._session)
839
+
840
+ if self._settings.memory_enabled:
841
+ await self._extract_memory_from(result.artifact_id, artifact_store, cost_tracker)
842
+
843
+ async def _extract_memory_from(
844
+ self, artifact_id: str, artifact_store: SessionArtifactStore, cost_tracker: CostTracker
845
+ ) -> None:
846
+ """Mirrors ChatScreen._extract_memory_from - best-effort: a failure
847
+ here is logged and otherwise invisible, since it never touched the
848
+ turn/command that triggered compaction."""
849
+ transcript = artifact_store.get(artifact_id)
850
+ if not transcript:
851
+ return
852
+ model = self._effective_compaction_model()
853
+ try:
854
+ extraction_ctx = replace(
855
+ make_tool_context(
856
+ self._runtime,
857
+ self._settings,
858
+ self._cwd,
859
+ session=self._session,
860
+ permission_manager=self._permission_manager,
861
+ artifact_store=artifact_store,
862
+ ),
863
+ model=model,
864
+ )
865
+ usages = await extract_memory(transcript, extraction_ctx)
866
+ except GatewayError as exc:
867
+ logger.exception("Gateway error during memory extraction: %s", exc.message)
868
+ return
869
+ if not usages:
870
+ return
871
+ for usage in usages:
872
+ cost_tracker.record_turn(model or "", usage, source="memory")
873
+ self._store.save(self._session)
874
+
875
+ async def handle_models_command(self, chat_id: int, arg: str | None) -> None:
876
+ """Mirrors ChatScreen._handle_models_command: an argument sets the
877
+ model directly (_set_model, same as chat.py's own); no argument
878
+ presents an inline-button picker - Telegram's equivalent of
879
+ chat.py's interactive ModelListScreen, reusing the same
880
+ callback_data mechanism permission prompts use (see
881
+ _encode_model_callback/_decode_model_callback and handle_callback
882
+ below). Model names can exceed Telegram's 64-byte callback_data
883
+ limit, so buttons carry a short random token instead of the name
884
+ itself - self._model_choices maps each token back to its model
885
+ name, rebuilt fresh on every /models call (a button from an older
886
+ listing silently stops working once a newer one replaces it, the
887
+ same "stale press is a harmless no-op" precedent
888
+ PendingApprovals already establishes for permission prompts)."""
889
+ if not self._is_authorized(chat_id):
890
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
891
+ return
892
+ if arg:
893
+ self._set_model(arg)
894
+ await self._sender.send_message(chat_id, f"Model set to '{arg}'.")
895
+ return
896
+
897
+ try:
898
+ models = await self._runtime.client.list_models()
899
+ except GatewayError as exc:
900
+ await self._sender.send_message(chat_id, f"Failed to list models: {exc.message}")
901
+ return
902
+
903
+ if not models:
904
+ await self._sender.send_message(chat_id, "Gateway returned no models.")
905
+ return
906
+
907
+ current = self._settings.default_model or self._session.model or None
908
+ shown, omitted = models[:_MAX_MODEL_BUTTONS], models[_MAX_MODEL_BUTTONS:]
909
+ self._model_choices = {secrets.token_hex(4): model for model in shown}
910
+ buttons = [
911
+ (f"{'* ' if model == current else ''}{model}", _encode_model_callback(token))
912
+ for token, model in self._model_choices.items()
913
+ ]
914
+ text = "Choose a model:" if not omitted else (
915
+ f"Choose a model (showing the first {len(shown)} of {len(models)} - use "
916
+ "/models <name> directly for one not listed):"
917
+ )
918
+ await self._sender.send_message(chat_id, text, buttons=buttons)
919
+
920
+ def _set_model(self, model: str) -> None:
921
+ self._settings.default_model = model
922
+ self._session.model = model
923
+ update_config_file(default_model=model)
924
+
925
+ async def handle_sessions_command(self, chat_id: int, rest: str) -> None:
926
+ """`/sessions` lists other sessions (`SessionStore.list_index` -
927
+ already sorted most-recently-updated first); `/sessions switch
928
+ <id>` loads one and makes it self._session - the same
929
+ single-attribute swap /new already does safely (an in-flight
930
+ turn, if any, holds its own reference to the old Session object
931
+ via run_headless_task's `session` parameter, so swapping
932
+ self._session mid-turn doesn't corrupt anything in flight).
933
+ Unlike `pcli --resume`, which requires the full id, `<id>` here
934
+ can be any suffix of it - same convenience /memory forget <id>
935
+ already gives, since typing a full id on a phone keyboard is
936
+ impractical. Not chat.py's full interactive SessionListScreen
937
+ browser (sort/search/delete) - a text list covers the real need
938
+ (resuming a previous conversation from the phone)."""
939
+ if not self._is_authorized(chat_id):
940
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
941
+ return
942
+ sub_command, _, arg = rest.partition(" ")
943
+ sub_command = sub_command.strip().lower()
944
+ arg = arg.strip()
945
+
946
+ if not sub_command:
947
+ entries = [e for e in self._store.list_index() if e.id != self._session.id][:20]
948
+ if not entries:
949
+ await self._sender.send_message(chat_id, "No other sessions yet.")
950
+ return
951
+ lines = [
952
+ f"- {entry.id[-4:]} {entry.title} ({entry.message_count} msg, "
953
+ f"${entry.total_cost_usd:.4f}, {entry.updated_at:%Y-%m-%d %H:%M})"
954
+ for entry in entries
955
+ ]
956
+ await self._sender.send_message(
957
+ chat_id,
958
+ "Other sessions (most recent first):\n"
959
+ + "\n".join(lines)
960
+ + "\n\nUsage: /sessions switch <id> (the short id shown above, or more of it)",
961
+ )
962
+ return
963
+
964
+ if sub_command == "switch":
965
+ if not arg:
966
+ await self._sender.send_message(chat_id, "Usage: /sessions switch <id>")
967
+ return
968
+ matches = [e for e in self._store.list_index() if e.id.endswith(arg)]
969
+ if not matches:
970
+ await self._sender.send_message(chat_id, f"No session found matching '{arg}'.")
971
+ return
972
+ if len(matches) > 1:
973
+ await self._sender.send_message(
974
+ chat_id, f"'{arg}' matches more than one session - use a longer id."
975
+ )
976
+ return
977
+ self._session = self._store.load(matches[0].id)
978
+ await self._sender.send_message(
979
+ chat_id, f"Switched to session '{self._session.derive_title()}'."
980
+ )
981
+ return
982
+
983
+ await self._sender.send_message(
984
+ chat_id,
985
+ f"Unknown /sessions subcommand: '{sub_command}'. Use /sessions, or /sessions "
986
+ "switch <id>.",
987
+ )
988
+
989
+ async def handle_export_command(self, chat_id: int, arg: str | None) -> None:
990
+ """Mirrors ChatScreen._export_current's path/export_session call,
991
+ but sends the result back as a Telegram document instead of just
992
+ reporting a local path. chat.py's version reporting a path is
993
+ fine for a user sitting at the same machine; a remote Telegram
994
+ user has no other access to the host's filesystem at all, so a
995
+ bare path would be useless - the whole point of Telegram here is
996
+ remote use."""
997
+ if not self._is_authorized(chat_id):
998
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
999
+ return
1000
+ from pcli.config.paths import data_dir
1001
+ from pcli.session.export import export_session
1002
+
1003
+ out_path = (
1004
+ Path(arg).expanduser()
1005
+ if arg
1006
+ else data_dir() / "exports" / f"{self._session.id}.pcli-session.json"
1007
+ )
1008
+ export_session(self._session, out_path, store=self._store)
1009
+ await self._sender.send_document(chat_id, out_path)
1010
+
1011
+ async def _set_plan_mode(self, chat_id: int, enabled: bool) -> None:
1012
+ """Mirrors ChatScreen._set_plan_mode - self._plan_mode is read by
1013
+ _process_turn on the next message (there's no live AgentLoop here
1014
+ to push a registry swap into immediately, same simplification
1015
+ already noted for the scalar-setting commands)."""
1016
+ if not self._is_authorized(chat_id):
1017
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
1018
+ return
1019
+ if enabled == self._plan_mode:
1020
+ await self._sender.send_message(
1021
+ chat_id, f"Already in {'plan' if enabled else 'build'} mode."
1022
+ )
1023
+ return
1024
+ self._plan_mode = enabled
1025
+ if enabled:
1026
+ await self._sender.send_message(
1027
+ chat_id,
1028
+ "Plan mode enabled: only read-only/exploration tools are available. Use "
1029
+ "/build to exit and allow writes/edits/shell commands again.",
1030
+ )
1031
+ else:
1032
+ await self._sender.send_message(chat_id, "Build mode restored: all tools are available again.")
1033
+
1034
+ async def handle_plan_command(self, chat_id: int) -> None:
1035
+ await self._set_plan_mode(chat_id, True)
1036
+
1037
+ async def handle_build_command(self, chat_id: int) -> None:
1038
+ await self._set_plan_mode(chat_id, False)
1039
+
1040
+ async def handle_new_command(self, chat_id: int) -> None:
1041
+ if not self._is_authorized(chat_id):
1042
+ return
1043
+ self._session = new_headless_session(self._store, self._settings, self._cwd)
1044
+ await self._sender.send_message(chat_id, "Started a new session.")
1045
+
1046
+ async def handle_unsupported_command(self, chat_id: int, command: str) -> None:
1047
+ """Telegram doesn't have every TUI slash command yet (e.g. /models,
1048
+ /sessions, /plan aren't implemented here - see daemon.py's own
1049
+ handle_*_command methods for the current set). Without this,
1050
+ bot.py's handler registration means anything not explicitly
1051
+ registered matches no handler at all and is silently dropped by
1052
+ python-telegram-bot itself, before TelegramDaemon ever sees it - a
1053
+ real reported confusion ("I sent /models and nothing happened").
1054
+ Same authorization gate as handle_text/handle_new_command."""
1055
+ if not self._is_authorized(chat_id):
1056
+ logger.warning("Ignored message from unauthorized chat id %s", chat_id)
1057
+ return
1058
+ await self._sender.send_message(
1059
+ chat_id,
1060
+ f"'{command}' isn't a command this Telegram bot supports yet. Anything else "
1061
+ "(no leading /) is sent to the agent as a normal message.",
1062
+ )
1063
+
1064
+ async def handle_callback(self, data: str) -> None:
1065
+ """No chat-id check here on purpose: a callback_data payload is
1066
+ meaningless unless it matches an id/token this exact process
1067
+ handed out (PendingApprovals.register for a "perm:" payload,
1068
+ self._model_choices for a "model:" one) - there's nothing for an
1069
+ unauthorized chat to forge here even if it somehow saw the
1070
+ button. Tries the permission-decode first since that's the
1071
+ hot path (every tool call needing approval goes through it);
1072
+ the model-decode is just as cheap to try when that one misses."""
1073
+ decoded = decode_callback_data(data)
1074
+ if decoded is not None:
1075
+ request_id, result = decoded
1076
+ self._pending_approvals.resolve(request_id, result)
1077
+ return
1078
+
1079
+ token = _decode_model_callback(data)
1080
+ if token is None:
1081
+ return
1082
+ model = self._model_choices.get(token)
1083
+ if model is None:
1084
+ return
1085
+ self._set_model(model)
1086
+ await self._sender.send_message(self._chat_id, f"Model set to '{model}'.")
1087
+
1088
+ async def run_forever(self) -> None:
1089
+ """The daemon's single-consumer loop - runs until cancelled (see
1090
+ cli.py's shutdown handling). A single bad turn (a bug in a tool, an
1091
+ unexpected exception) is caught and reported back to the chat
1092
+ rather than killing the whole daemon - a long-running process
1093
+ shouldn't die because one message went badly."""
1094
+ while True:
1095
+ text = await self._queue.get()
1096
+ try:
1097
+ await self._process(text)
1098
+ except Exception:
1099
+ logger.exception("Unhandled error processing a Telegram message")
1100
+ try:
1101
+ await self._sender.send_message(
1102
+ self._chat_id,
1103
+ "Something went wrong handling that - check pcli's logs.",
1104
+ )
1105
+ except Exception:
1106
+ logger.exception("Also failed to report that error back to Telegram")
1107
+
1108
+ async def _process(self, text: str) -> None:
1109
+ # Guards /compact below the same way ChatScreen._turn_in_progress
1110
+ # guards its own /compact - maybe_compact mutates session.messages
1111
+ # directly, which would race a turn also reading/appending to that
1112
+ # same list if the two ran concurrently.
1113
+ self._turn_in_progress = True
1114
+ try:
1115
+ await self._process_turn(text)
1116
+ finally:
1117
+ self._turn_in_progress = False
1118
+
1119
+ async def _process_turn(self, text: str) -> None:
1120
+ # A turn can take a while (several tool calls) with nothing else
1121
+ # sent back until it finishes - without this, a message mid-turn
1122
+ # looks identical to one that was never received at all. One
1123
+ # upfront acknowledgment is the right-sized fix for a chat
1124
+ # interface.
1125
+ await self._sender.send_message(self._chat_id, "Working on it...")
1126
+
1127
+ # run_headless_task's on_progress is a plain sync callback (it has
1128
+ # to be - it's called from inside an async-for loop it doesn't
1129
+ # control, see headless.py), so it can't await the real Telegram
1130
+ # send itself. It just queues the line; a background task drains
1131
+ # the queue and does the actual sending, so each tool call and its
1132
+ # result reaches the chat as it happens instead of only after the
1133
+ # whole turn finishes.
1134
+ progress: asyncio.Queue[str | None] = asyncio.Queue()
1135
+
1136
+ def on_progress(line: str) -> None:
1137
+ if not line.startswith("> "): # the echoed task text itself - redundant, skip
1138
+ progress.put_nowait(line)
1139
+
1140
+ async def forward_progress() -> None:
1141
+ while True:
1142
+ line = await progress.get()
1143
+ if line is None:
1144
+ return
1145
+ await self._sender.send_message(self._chat_id, line)
1146
+
1147
+ forwarder = asyncio.create_task(forward_progress())
1148
+
1149
+ async def ask(tool_name: str, arguments: dict, risk_description: str):
1150
+ return await ask_via_telegram(
1151
+ self._sender,
1152
+ self._pending_approvals,
1153
+ self._chat_id,
1154
+ tool_name,
1155
+ arguments,
1156
+ risk_description,
1157
+ )
1158
+
1159
+ tool_registry = (
1160
+ self._runtime.tool_registry.filtered(lambda t: t.plan_mode_safe)
1161
+ if self._plan_mode
1162
+ else None
1163
+ )
1164
+
1165
+ try:
1166
+ result = await run_headless_task(
1167
+ text,
1168
+ session=self._session,
1169
+ runtime=self._runtime,
1170
+ settings=self._settings,
1171
+ permission_manager=self._permission_manager,
1172
+ cwd=self._cwd,
1173
+ store=self._store,
1174
+ on_progress=on_progress,
1175
+ ask=ask,
1176
+ plan_mode=self._plan_mode,
1177
+ tool_registry=tool_registry,
1178
+ )
1179
+ except GatewayError as exc:
1180
+ # Drained before sending the error, not after, so any tool
1181
+ # calls that did complete before the gateway failed still show
1182
+ # up in the chat ahead of the error message instead of behind it.
1183
+ await progress.put(None)
1184
+ await forwarder
1185
+ await self._sender.send_message(self._chat_id, f"Gateway error: {exc.message}")
1186
+ return
1187
+
1188
+ await progress.put(None)
1189
+ await forwarder
1190
+
1191
+ await self._sender.send_message(self._chat_id, result.final_text or "(no reply)")
1192
+ if result.truncations_exhausted:
1193
+ await self._sender.send_message(
1194
+ self._chat_id,
1195
+ "Response kept getting cut off by the token limit even after retrying - it "
1196
+ "may be incomplete. Send another message to continue.",
1197
+ )