zettcode-sdk 0.4.1__tar.gz → 0.4.2__tar.gz

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 (34) hide show
  1. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/.gitignore +5 -0
  2. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/PKG-INFO +14 -2
  3. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/pyproject.toml +20 -16
  4. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/process.py +27 -9
  5. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/run.py +48 -5
  6. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/state.py +25 -0
  7. zettcode_sdk-0.4.2/src/zettcode/mentions.py +120 -0
  8. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/builtins.py +62 -310
  9. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/client.py +246 -26
  10. zettcode_sdk-0.4.2/src/zettcode/sdk/commandline.py +74 -0
  11. zettcode_sdk-0.4.2/src/zettcode/sdk/commands.py +21 -0
  12. zettcode_sdk-0.4.2/src/zettcode/sdk/relay.py +50 -0
  13. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/session.py +20 -26
  14. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/translation.py +33 -4
  15. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/types.py +7 -2
  16. zettcode_sdk-0.4.1/src/zettcode/mentions.py +0 -320
  17. zettcode_sdk-0.4.1/src/zettcode/sdk/commands.py +0 -9
  18. zettcode_sdk-0.4.1/src/zettcode/sdk/extensions/mcp.py +0 -276
  19. zettcode_sdk-0.4.1/src/zettcode/sdk/extensions/messaging.py +0 -281
  20. zettcode_sdk-0.4.1/src/zettcode/sdk/messenger.py +0 -299
  21. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/__init__.py +0 -0
  22. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/__main__.py +0 -0
  23. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/cli.py +0 -0
  24. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/daemon/companions.py +0 -0
  25. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/doctor.py +0 -0
  26. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/__init__.py +0 -0
  27. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/daemons.py +0 -0
  28. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/errors.py +0 -0
  29. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/events.py +0 -0
  30. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/extensions/__init__.py +0 -0
  31. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/handlers.py +0 -0
  32. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/models.py +0 -0
  33. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/sdk/py.typed +0 -0
  34. {zettcode_sdk-0.4.1 → zettcode_sdk-0.4.2}/src/zettcode/table.py +0 -0
@@ -35,3 +35,8 @@ docs/internal/palettes.html
35
35
 
36
36
  # The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
37
37
  packages/order.txt
38
+
39
+ # The desktop and phone apps build the gateway's page into their own folders, and electron-builder writes its
40
+ # installers under release/.
41
+ clients/desktop/renderer/
42
+ clients/desktop/release/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zettcode-sdk
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: The documented, stable Python surface over ZettCode: sessions, runs, models, daemons
5
5
  Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
6
  Author: Chang-LeHung
@@ -16,6 +16,18 @@ Classifier: Programming Language :: Python :: 3.15
16
16
  Classifier: Typing :: Typed
17
17
  Requires-Python: >=3.10
18
18
  Requires-Dist: httpx<1,>=0.27
19
- Requires-Dist: zett-agent<0.3,>=0.2.7
19
+ Requires-Dist: zett-agent<0.3,>=0.2.10
20
+ Requires-Dist: zettcode-ask-user
20
21
  Requires-Dist: zettcode-broker
21
22
  Requires-Dist: zettcode-engine
23
+ Requires-Dist: zettcode-files
24
+ Requires-Dist: zettcode-guidance
25
+ Requires-Dist: zettcode-instructions
26
+ Requires-Dist: zettcode-jit-harness
27
+ Requires-Dist: zettcode-mcp
28
+ Requires-Dist: zettcode-memory
29
+ Requires-Dist: zettcode-messaging
30
+ Requires-Dist: zettcode-skills
31
+ Requires-Dist: zettcode-todos
32
+ Requires-Dist: zettcode-tool-output
33
+ Requires-Dist: zettcode-truncation
@@ -19,33 +19,37 @@ classifiers = [
19
19
  dependencies = [
20
20
  "zettcode-engine",
21
21
  "zettcode-broker",
22
- "zett-agent>=0.2.7,<0.3",
22
+ # Parts of the agent that live in distributions of their own, found through the same `zettcode.plugin` group as
23
+ # the ones published here. An SDK client offers skills and MCP servers and runs the same agent, so it installs them
24
+ # all; `require_agent_parts` refuses a start that lacks one.
25
+ "zettcode-skills",
26
+ "zettcode-messaging",
27
+ "zettcode-mcp",
28
+ "zettcode-files",
29
+ "zettcode-jit-harness",
30
+ "zettcode-ask-user",
31
+ "zettcode-instructions",
32
+ "zettcode-memory",
33
+ "zettcode-todos",
34
+ "zettcode-tool-output",
35
+ "zettcode-truncation",
36
+ "zettcode-guidance",
37
+ "zett-agent>=0.2.10,<0.3",
23
38
  "httpx>=0.27,<1",
24
39
  ]
25
40
 
26
- # The plugins that compose the agent. A part of the program, not a plugin: the `zettcode.plugins` group is what the
27
- # plugin release line looks for. They are activated by the rank `builtins.py` gives them, then by their `deps`.
28
- [project.entry-points."zettcode.agent"]
29
- skills = "zettcode.sdk.builtins:SkillResources"
30
- files = "zettcode.sdk.builtins:FileResources"
41
+ # The plugins that compose the agent. A part of the program, not a plugin: the `[tool.zettcode] plugin = true`
42
+ # marker is what the plugin release line looks for. They are activated by the rank `builtins.py` gives them, then by their `deps`.
43
+ [project.entry-points."zettcode.plugin"]
31
44
  coding = "zettcode.sdk.builtins:CodingTools"
32
- tool-output = "zettcode.sdk.builtins:ToolOutput"
33
- long-output = "zettcode.sdk.builtins:LongOutput"
45
+ commandline = "zettcode.sdk.builtins:CommandLine"
34
46
  capabilities = "zettcode.sdk.builtins:Capabilities"
35
47
  subagents = "zettcode.sdk.builtins:Subagents"
36
- messaging = "zettcode.sdk.builtins:Messaging"
37
48
  shell-permission = "zettcode.sdk.builtins:ShellPermission"
38
- harness = "zettcode.sdk.builtins:Harness"
39
49
  session-log = "zettcode.sdk.builtins:SessionLog"
40
- todos = "zettcode.sdk.builtins:TodoPlan"
41
50
  usage-tracking = "zettcode.sdk.builtins:UsageTracking"
42
51
  context-tracking = "zettcode.sdk.builtins:ContextTracking"
43
52
  side-questions = "zettcode.sdk.builtins:SideQuestionBoundary"
44
- tool-guidelines = "zettcode.sdk.builtins:ToolGuidance"
45
- ask-user = "zettcode.sdk.builtins:AskUser"
46
- project-instructions = "zettcode.sdk.builtins:ProjectInstructions"
47
- skill-tool = "zettcode.sdk.builtins:SkillTool"
48
- mcp = "zettcode.sdk.builtins:McpTools"
49
53
  self-compaction = "zettcode.sdk.builtins:SelfCompaction"
50
54
  compaction = "zettcode.sdk.builtins:Compaction"
51
55
 
@@ -186,7 +186,9 @@ class Daemons:
186
186
 
187
187
  By default the record is removed too (``kill``); with ``keep`` it stays, as a dead one, so the daemon is
188
188
  still listed and can be started again or deleted. A dead record is only removed (or, with ``keep``, left):
189
- the process it described is gone already. Whether it is ``working`` is read from the state file, which can be a
189
+ the process it described is gone already. The one record ``keep`` does not keep is a folder with no readable
190
+ state: it names no session and no workspace, so there is nothing to start again, and it is removed. Whether
191
+ it is ``working`` is read from the state file, which can be a
190
192
  moment old, so a daemon that has just changed turn may be judged by the one before; ``--force`` is the answer to
191
193
  that, not a retry. The session is never touched — it is a conversation like any
192
194
  other, and ``zettcode resume <id>`` opens it afterwards.
@@ -201,6 +203,7 @@ class Daemons:
201
203
  return "removed: it never recorded what it was"
202
204
  if not record.alive:
203
205
  if keep:
206
+ record.end_beat()
204
207
  return "was already gone; its record was kept"
205
208
  record.remove()
206
209
  return "was already gone; its record was removed"
@@ -218,22 +221,37 @@ class Daemons:
218
221
  deadline = time.monotonic() + grace
219
222
  while time.monotonic() < deadline:
220
223
  # The daemon removes its own record on the way out (unless asked to keep it), so the folder going is the
221
- # proof it stopped — a pid can outlive the process it named, as a zombie its parent has not reaped.
222
- if not record.folder.exists() or not pid_runs(pid):
223
- if keep:
224
- return "stopped; its record was kept"
225
- record.remove()
226
- return "stopped"
224
+ # proof it stopped — a pid can outlive the process it named, as a zombie its parent has not reaped. A
225
+ # record that was asked to stay has no folder to go, and its proof is the heartbeat the daemon takes away.
226
+ gone = not record.folder.exists()
227
+ if gone or not pid_runs(pid) or (keep and not record.beating()):
228
+ if not keep:
229
+ record.remove()
230
+ return "stopped"
231
+ if gone:
232
+ return "stopped; its record was removed"
233
+ # The process may have left without reaching its own last act (killed from elsewhere, an error while
234
+ # the client closed), and then the heartbeat is still fresh: take it away, so the record reads dead by
235
+ # the rule and not by a pid the system can hand to another process within the beat's thirty seconds.
236
+ record.end_beat()
237
+ return "stopped; its record was kept"
227
238
  time.sleep(POLL_SECONDS)
228
239
  _signal(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
229
- if not keep:
240
+ if keep:
241
+ record.end_beat() # killed, it cannot take its own heartbeat away
242
+ else:
230
243
  record.remove()
231
244
  return "did not stop in time, so it was killed"
232
245
 
233
246
  @staticmethod
234
247
  def _gone(record: Record, keep: bool, said: str) -> str:
235
- """Finish a stop whose process was already gone: keep the record or remove it."""
248
+ """Finish a stop whose process was already gone: keep the record or remove it.
249
+
250
+ A kept record loses its heartbeat like on the other two ways out, so it reads as dead by the same rule and not
251
+ by the pid alone, which a busy machine can reuse before the beat has gone stale.
252
+ """
236
253
  if keep:
254
+ record.end_beat()
237
255
  return f"{said}; its record was kept"
238
256
  record.remove()
239
257
  return f"{said}; its record was removed"
@@ -24,7 +24,7 @@ from pathlib import Path
24
24
  from typing import TYPE_CHECKING, Any
25
25
 
26
26
  from zettcode.error_text import may_pass
27
- from zettcode.relay import batch_size
27
+ from zettcode.messaging.relayed import batch_size
28
28
  from zettcode.sdk.types import DIRECT_EMPTY, DIRECT_FAILED, IncomingMessage, Receipt
29
29
 
30
30
  from .state import Record, Registry, State
@@ -98,7 +98,7 @@ class Inbox:
98
98
  def done(self) -> IncomingMessage | None:
99
99
  """Close the turn that ended: return the next message to run, or ``None`` when the daemon is idle again.
100
100
 
101
- The messages that waited come back as one, in order (see :func:`~zettcode.relay.batch_size`), so a daemon that
101
+ The messages that waited come back as one, in order (see :func:`~zettcode.messaging.relayed.batch_size`), so a daemon that
102
102
  was busy answers them in a single turn instead of one turn each; a chat window's message is always its own.
103
103
  """
104
104
  if self._waiting:
@@ -160,7 +160,11 @@ class Daemon:
160
160
  """Take a message from the broker: refused at once when it carries pictures this model cannot read.
161
161
 
162
162
  The sender is told then and there, in words it can act on, rather than after a turn that was bound to fail.
163
+ A daemon that has been asked to stop takes nothing: the turn would run while the client is being closed, and
164
+ its first state write would put a heartbeat back after the stop took it away.
163
165
  """
166
+ if self.stopping.is_set():
167
+ return Receipt(False, "this daemon is stopping")
164
168
  if message.images:
165
169
  try:
166
170
  model = self.client.model
@@ -196,12 +200,30 @@ class Daemon:
196
200
  # answers. A stop that asked to keep it leaves it, as a dead one the reader can start again or delete.
197
201
  if not self._kept():
198
202
  self.record.remove()
203
+ else:
204
+ # Nothing removes the folder now, so the daemon says it is gone the one way that does not depend on
205
+ # its pid: it takes its heartbeat away, and the record reads as dead from here on.
206
+ self.record.end_beat()
199
207
  return EXIT_OK
200
208
 
201
209
  def _kept(self) -> bool:
202
210
  """Return whether the stop that is ending this process asked for the record to stay."""
203
- state = State.load(self.record.folder)
204
- return state is not None and state.keep
211
+ return self._asked_to_keep(State.load(self.record.folder))
212
+
213
+ def _asked_to_keep(self, current: State | None) -> bool:
214
+ """Return whether ``current`` carries the ask to keep the record that was made of *this* process.
215
+
216
+ A stop writes ``keep`` into the state before it signals, and every later write of this process carries it, so
217
+ the daemon's last write cannot put it back: whatever status a turn that was already starting wrote over the
218
+ stop's, the field survives it. What may not be believed is a field left in the folder by an earlier start of
219
+ the same session (a daemon started again on a kept record finds it there, and would leave a record behind on
220
+ Ctrl-C that it used to remove). The token is this start's own and the pid is this process's: the token alone
221
+ names the state this process wrote, and the pid alone could be a stopped daemon's pid the system gave to the
222
+ new one, so both have to say so.
223
+ """
224
+ return bool(
225
+ current is not None and current.keep and current.token == self.options.token and current.pid == os.getpid()
226
+ )
205
227
 
206
228
  # -- what it answers ---------------------------------------------------------------------------
207
229
  def _start(self, message: IncomingMessage) -> None:
@@ -209,7 +231,13 @@ class Daemon:
209
231
  asyncio.ensure_future(self._answer(message))
210
232
 
211
233
  async def _answer(self, message: IncomingMessage) -> None:
212
- """Run one turn, then the next waiting message, and go idle when there is none."""
234
+ """Run one turn, then the next waiting message, and go idle when there is none.
235
+
236
+ A message queued before the stop is not answered after it: the process is leaving, and the turn would run
237
+ while the client is being closed.
238
+ """
239
+ if self.stopping.is_set():
240
+ return
213
241
  try:
214
242
  await self._apply_requests()
215
243
  await self.mark("working")
@@ -399,7 +427,13 @@ class Daemon:
399
427
 
400
428
  The heartbeat is touched here as well as on its own clock: the first state a reader can see is written with
401
429
  proof of life beside it, so a daemon is never listed as dead between its start and its first beat.
430
+
431
+ Once the process has been asked to stop, the only state it writes is ``stopping``: whatever a task that was
432
+ already running says after that would also beat, and a heartbeat written after the daemon took its own away
433
+ reads as a daemon that is still there.
402
434
  """
435
+ if self.stopping.is_set() and status != "stopping":
436
+ return
403
437
  self.record.beat()
404
438
  sent, made, context = self._spend()
405
439
  state = State(
@@ -431,6 +465,10 @@ class Daemon:
431
465
  requested_model=current.requested_model if current is not None else None,
432
466
  requested_effort=current.requested_effort if current is not None else None,
433
467
  requested_error=current.requested_error if current is not None else None,
468
+ # A stop that wants the record kept writes the field before it signals, and this write is the
469
+ # daemon's last: built afresh, the state would say "do not keep" and the daemon would remove
470
+ # the record it was asked to leave. What counts as this process's ask is `_asked_to_keep`.
471
+ keep=self._asked_to_keep(current),
434
472
  ),
435
473
  wait=0,
436
474
  )
@@ -619,6 +657,11 @@ async def serve(options: Options) -> int:
619
657
  await client.sessions.open(options.session)
620
658
  except (ConfigurationError, SessionNotFoundError, SessionLockedError) as error:
621
659
  return _failed(str(error))
660
+ if client.session.locked:
661
+ # `open` does not refuse a session another process holds: it selects it, to be read and not
662
+ # written. That is a fine thing for a reader and no thing at all for a daemon, whose every turn
663
+ # would fail on the first write while the daemon sat there listed as idle.
664
+ return _failed("this session is open in another ZettCode process, so a daemon cannot write to it")
622
665
  if options.title:
623
666
  # The session this process holds, so the name is written where the daemon and `/resume` both read it.
624
667
  try:
@@ -182,6 +182,15 @@ class Record:
182
182
  Reading a record reads its state once, at construction, and whether the process is there: a command that lists
183
183
  ten daemons asks the disk ten times, and nothing between two reads of one record can change the answer without
184
184
  the file changing too.
185
+
186
+ **When a record reads as alive.** Only while its pid answers *and* its heartbeat is fresh; either alone is not
187
+ proof (a pid is reused, and a zombie its parent has not reaped still answers signal zero). A record that is left
188
+ behind on purpose (a stop that asked to keep it) has no folder going away to say the process has stopped, so the
189
+ process says it itself: :meth:`end_beat` is the last thing it writes, and **nothing may beat after it** — which is
190
+ why ``Daemon.mark`` refuses every state but ``stopping`` once the daemon is stopping, ``take`` and ``_answer``
191
+ refuse work, and the stop paths in :mod:`.process` that find the process already gone take the beat away too.
192
+ A new writer of this folder that touches the heartbeat has to respect that, or a stopped daemon reads as running
193
+ for the thirty seconds a beat stays fresh (invariant H29).
185
194
  """
186
195
 
187
196
  def __init__(self, folder: Path) -> None:
@@ -270,6 +279,22 @@ class Record:
270
279
  except OSError:
271
280
  pass
272
281
 
282
+ def end_beat(self) -> None:
283
+ """Take the heartbeat away: the daemon's last word when it leaves its record behind.
284
+
285
+ A record that stays has no folder going away to say the process has stopped, and a pid is no proof either —
286
+ a zombie its parent has not reaped still answers signal zero. No heartbeat is: the record then reads as dead
287
+ at once, instead of after the thirty seconds a stale beat takes.
288
+ """
289
+ try:
290
+ (self.folder / BEAT_FILE).unlink()
291
+ except OSError:
292
+ pass
293
+
294
+ def beating(self) -> bool:
295
+ """Return whether a heartbeat file is there to be read, whatever its age."""
296
+ return (self.folder / BEAT_FILE).exists()
297
+
273
298
  def log(self, text: str) -> None:
274
299
  """Append one line to the daemon's log; a log that cannot be written is not worth failing a daemon over."""
275
300
  stamp = time.strftime("%Y-%m-%d %H:%M:%S")
@@ -0,0 +1,120 @@
1
+ """``@`` references: resources a draft can pull into one message.
2
+
3
+ A mention is a token like ``@code-review`` typed in the composer. A provider
4
+ owns one kind of resource (other sessions here; files in ``zettcode-files``, skills in ``zettcode-skills``) and both
5
+ lists candidates for the completion menu and expands a token into the text the model
6
+ should see. The draft the user typed stays the message that is stored and
7
+ shown; the expansion is carried as a message attribute, so a session loaded
8
+ later restores the original text instead of the injected resource.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Sequence
14
+
15
+ from zettcode.broker.text import folder_name
16
+ from zettcode.mention import Mention, MentionProvider, MentionRegistry
17
+ from zettcode.wire import RosterPeer
18
+
19
+ __all__ = [
20
+ "Mention",
21
+ "MentionProvider",
22
+ "MentionRegistry",
23
+ "RosterPeer",
24
+ "SessionMentions",
25
+ ]
26
+
27
+
28
+ class SessionMentions(MentionProvider):
29
+ """The other ZettCode sessions that are running, addressed by the name they are listed under.
30
+
31
+ A session named anywhere in a draft is a reference like any other: the model reads ``session "name"``
32
+ and is told how to reach that session, and decides for itself whether the request calls for a message.
33
+ The shell sends nothing itself (see :mod:`zettcode.app.ui.messaging`).
34
+
35
+ Candidates are read from what the connection last reported — a list in memory — and never from
36
+ the broker: completion runs on every keystroke after an ``@``.
37
+ """
38
+
39
+ kind = "session"
40
+ #: A session is the most specific thing a name can mean, ahead of a skill and a file.
41
+ precedence = -20
42
+
43
+ def __init__(self, peers: Callable[[], Sequence[RosterPeer]]) -> None:
44
+ """Offer whichever sessions ``peers`` returns when asked.
45
+
46
+ Args:
47
+ peers: The other sessions as of now; called for every query, so it must not block.
48
+ """
49
+ self._peers = peers
50
+
51
+ def candidates(self, query: str) -> Sequence[Mention]:
52
+ """List the sessions ``query`` could mean, the likeliest first.
53
+
54
+ A reader looks for a session by what they remember of it, which is its name, what it was about,
55
+ the folder it works in, or the start of its id — so any of those matches, with a session whose
56
+ *name* starts with the query before one that only mentions it elsewhere. The token offered is
57
+ always the name, because that is what can be sent to.
58
+ """
59
+ lowered = query.lower()
60
+ ranked: list[tuple[int, int, RosterPeer]] = []
61
+ for order, peer in enumerate(self._peers()):
62
+ if peer.handle.lower().startswith(lowered):
63
+ ranked.append((0, order, peer))
64
+ elif (
65
+ lowered in peer.handle.lower() or lowered in peer.title.lower() or lowered in self._folder(peer).lower()
66
+ ):
67
+ ranked.append((1, order, peer))
68
+ elif len(lowered) >= 4 and peer.session_id.lower().startswith(lowered):
69
+ ranked.append((2, order, peer))
70
+ return tuple(
71
+ Mention(token=f"@{peer.handle}", kind=self.kind, name=peer.handle, description=self._describe(peer))
72
+ for _, _, peer in sorted(ranked, key=lambda item: item[:2])
73
+ )
74
+
75
+ @staticmethod
76
+ def _folder(peer: RosterPeer) -> str:
77
+ """Return the last part of the directory the session works in."""
78
+ return folder_name(peer.workspace)
79
+
80
+ @classmethod
81
+ def _describe(cls, peer: RosterPeer) -> str:
82
+ """Return the menu's line for a session: what it is about, whether it is working, where it is."""
83
+ parts = [peer.title or "untitled"]
84
+ if peer.status != "idle":
85
+ parts.append(peer.status)
86
+ folder = cls._folder(peer)
87
+ if folder:
88
+ parts.append(folder)
89
+ return " \u00b7 ".join(parts)
90
+
91
+ def rename(self, name: str) -> str | None:
92
+ """Put ``session "work"`` where the reader typed ``@work``: the model has no use for the ``@``."""
93
+ return f'session "{name}"' if self._find(name) is not None else None
94
+
95
+ def _find(self, name: str) -> RosterPeer | None:
96
+ """Return the session called ``name`` among those running now."""
97
+ return next((peer for peer in self._peers() if peer.handle == name), None)
98
+
99
+ def expand(self, name: str) -> str | None:
100
+ """Tell the model what the reader's ``@`` meant and how to reach that session.
101
+
102
+ Nothing is sent from here: the model is told which session the words mean and how to reach it, and
103
+ decides for itself whether the request calls for a message. A draft that begins with the name is no
104
+ different from one that names the session inside a sentence.
105
+ """
106
+ peer = self._find(name)
107
+ if peer is None:
108
+ return None
109
+ where = f" in {peer.workspace}" if peer.workspace else ""
110
+ about = f' It is titled "{peer.title}".' if peer.title else ""
111
+ return (
112
+ f'In the message above, `session "{name}"` is what the user wrote as @{name}. In ZettCode an @ before a '
113
+ f"name means another ZettCode session that is running right now, not a file or a skill. This one works{where}.{about}\n"
114
+ f"When the user's message is addressed to that session — the @ begins the message — what follows the name "
115
+ f"is the message to pass on. When the session is only mentioned, decide whether the request is for it. "
116
+ f'To send, call the send_message tool: send_message(to="{peer.session_id}", message=...). Write the '
117
+ f"message so the other session can act on it without having seen this conversation. If the request is only "
118
+ f"about the session (what it is doing, where it is), list_sessions answers that. Do not send anything the "
119
+ f"user did not ask you to pass on."
120
+ )