zettcode 0.1.6__tar.gz → 0.1.7__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 (198) hide show
  1. {zettcode-0.1.6 → zettcode-0.1.7}/PKG-INFO +5 -1
  2. {zettcode-0.1.6 → zettcode-0.1.7}/pyproject.toml +7 -0
  3. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/__init__.py +1 -1
  4. zettcode-0.1.7/src/zettcode/app/agent/agent/base.py +54 -0
  5. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/agent/context.py +2 -0
  6. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/agent/prompt.py +26 -0
  7. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/agent/sessions.py +6 -0
  8. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/agent/turns.py +14 -4
  9. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/entries.py +50 -0
  10. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/presenters.py +160 -8
  11. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/projection.py +16 -2
  12. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/rendering.py +36 -1
  13. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/replay.py +7 -1
  14. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/rows.py +13 -0
  15. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/transcript/turn.py +69 -3
  16. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/dialogue/mentions.py +114 -0
  17. zettcode-0.1.7/src/zettcode/app/agent/dialogue/relay.py +153 -0
  18. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/dialogue/side.py +1 -1
  19. zettcode-0.1.7/src/zettcode/app/agent/errors.py +125 -0
  20. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/export/__init__.py +9 -2
  21. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/extensions/mcp.py +24 -2
  22. zettcode-0.1.7/src/zettcode/app/agent/extensions/messaging.py +130 -0
  23. zettcode-0.1.7/src/zettcode/app/agent/extensions/messenger.py +183 -0
  24. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/runtime.py +49 -5
  25. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/storage/__init__.py +8 -0
  26. zettcode-0.1.7/src/zettcode/app/agent/storage/locks.py +130 -0
  27. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/app.py +81 -10
  28. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/boot.py +5 -2
  29. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/commands.py +4 -0
  30. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/followups.py +8 -5
  31. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/labels.py +40 -6
  32. zettcode-0.1.7/src/zettcode/app/ui/messaging.py +244 -0
  33. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/providers.py +9 -5
  34. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/rows.py +1 -0
  35. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/sessions.py +89 -0
  36. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/shell.py +26 -1
  37. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/composer.py +98 -2
  38. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/transcript.py +3 -2
  39. zettcode-0.1.7/src/zettcode/broker/__init__.py +22 -0
  40. zettcode-0.1.7/src/zettcode/broker/__main__.py +10 -0
  41. zettcode-0.1.7/src/zettcode/broker/client.py +533 -0
  42. zettcode-0.1.7/src/zettcode/broker/files.py +131 -0
  43. zettcode-0.1.7/src/zettcode/broker/finder.py +190 -0
  44. zettcode-0.1.7/src/zettcode/broker/hub.py +275 -0
  45. zettcode-0.1.7/src/zettcode/broker/log.py +11 -0
  46. zettcode-0.1.7/src/zettcode/broker/models.py +77 -0
  47. zettcode-0.1.7/src/zettcode/broker/process.py +117 -0
  48. zettcode-0.1.7/src/zettcode/broker/protocol.py +282 -0
  49. zettcode-0.1.7/src/zettcode/broker/roster.py +176 -0
  50. zettcode-0.1.7/src/zettcode/broker/runner.py +131 -0
  51. zettcode-0.1.7/src/zettcode/broker/server.py +184 -0
  52. zettcode-0.1.7/src/zettcode/broker/text.py +81 -0
  53. zettcode-0.1.7/src/zettcode/broker/web.py +192 -0
  54. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/config.py +27 -2
  55. zettcode-0.1.7/src/zettcode/filelock.py +125 -0
  56. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/plugins/builtins.py +4 -4
  57. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/plugins/state.py +3 -0
  58. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/providers.py +15 -8
  59. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/theme.py +32 -17
  60. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/layout/scroll.py +41 -37
  61. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/render/__init__.py +2 -0
  62. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/render/text.py +43 -0
  63. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/theme_file.py +3 -2
  64. zettcode-0.1.6/src/zettcode/app/agent/agent/base.py +0 -29
  65. zettcode-0.1.6/src/zettcode/app/agent/errors.py +0 -39
  66. {zettcode-0.1.6 → zettcode-0.1.7}/.gitignore +0 -0
  67. {zettcode-0.1.6 → zettcode-0.1.7}/LICENSE +0 -0
  68. {zettcode-0.1.6 → zettcode-0.1.7}/README.md +0 -0
  69. {zettcode-0.1.6 → zettcode-0.1.7}/README.zh-CN.md +0 -0
  70. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/_compat.py +0 -0
  71. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/__init__.py +0 -0
  72. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/__init__.py +0 -0
  73. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/agent/__init__.py +0 -0
  74. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/agent/commands.py +0 -0
  75. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/agent/exports.py +0 -0
  76. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/agent/interaction.py +0 -0
  77. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/agent/settings.py +0 -0
  78. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/__init__.py +0 -0
  79. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/changes.py +0 -0
  80. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/transcript/__init__.py +0 -0
  81. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/transcript/fold.py +0 -0
  82. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/transcript/log.py +0 -0
  83. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/transcript/oneoff.py +0 -0
  84. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/conversation/transcript/stream.py +0 -0
  85. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/dialogue/__init__.py +0 -0
  86. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/dialogue/ask.py +0 -0
  87. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/dialogue/title.py +0 -0
  88. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/export/trace/script.js +0 -0
  89. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/export/trace/style.css +0 -0
  90. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/export/trace/template.html +0 -0
  91. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/extensions/__init__.py +0 -0
  92. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/extensions/capabilities.py +0 -0
  93. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/extensions/compaction.py +0 -0
  94. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/extensions/context.py +0 -0
  95. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/extensions/mcp_notice.py +0 -0
  96. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/extensions/subagents.py +0 -0
  97. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/extensions/tool_output.py +0 -0
  98. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/extensions/usage.py +0 -0
  99. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/permission/__init__.py +0 -0
  100. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/permission/approval.py +0 -0
  101. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/permission/readonly.py +0 -0
  102. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/storage/metadata.py +0 -0
  103. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/storage/records.py +0 -0
  104. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/agent/storage/store.py +0 -0
  105. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/brand.py +0 -0
  106. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/commands.py +0 -0
  107. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/pipeline.py +0 -0
  108. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/registry.py +0 -0
  109. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/__init__.py +0 -0
  110. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/clipboard.py +0 -0
  111. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/config_form.py +0 -0
  112. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/demo.py +0 -0
  113. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/gc_policy.py +0 -0
  114. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/keys.py +0 -0
  115. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/notices.py +0 -0
  116. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/settings.py +0 -0
  117. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/updating.py +0 -0
  118. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/__init__.py +0 -0
  119. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/approval_page.py +0 -0
  120. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/ask_page.py +0 -0
  121. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/completer.py +0 -0
  122. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/context_page.py +0 -0
  123. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/follow_up.py +0 -0
  124. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/input_page.py +0 -0
  125. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/model_page.py +0 -0
  126. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/panel.py +0 -0
  127. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/root.py +0 -0
  128. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/send_kind_page.py +0 -0
  129. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/sessions_page.py +0 -0
  130. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/steering.py +0 -0
  131. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/update_page.py +0 -0
  132. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/app/ui/widgets/welcome.py +0 -0
  133. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/cli.py +0 -0
  134. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/collector.py +0 -0
  135. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/config_edit.py +0 -0
  136. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/paths.py +0 -0
  137. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/plugins/__init__.py +0 -0
  138. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/plugins/container.py +0 -0
  139. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/plugins/extension.py +0 -0
  140. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/plugins/loader.py +0 -0
  141. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/plugins/mixins.py +0 -0
  142. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/plugins/plugin.py +0 -0
  143. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/__init__.py +0 -0
  144. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/capabilities.py +0 -0
  145. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/clipboard.py +0 -0
  146. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/console.py +0 -0
  147. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/__init__.py +0 -0
  148. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/app.py +0 -0
  149. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/events.py +0 -0
  150. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/focus.py +0 -0
  151. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/geometry.py +0 -0
  152. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/host.py +0 -0
  153. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/keymap.py +0 -0
  154. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/scheduler.py +0 -0
  155. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/screen.py +0 -0
  156. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/core/widget.py +0 -0
  157. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/gallery.py +0 -0
  158. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/glyphs.py +0 -0
  159. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/input/__init__.py +0 -0
  160. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/input/bridge.py +0 -0
  161. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/input/decoder.py +0 -0
  162. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/input/events.py +0 -0
  163. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/input/keys.py +0 -0
  164. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/input/reader.py +0 -0
  165. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/layout/__init__.py +0 -0
  166. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/layout/box.py +0 -0
  167. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/layout/overlay.py +0 -0
  168. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/layout/solver.py +0 -0
  169. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/layout/spacing.py +0 -0
  170. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/render/canvas.py +0 -0
  171. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/render/code.py +0 -0
  172. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/render/color.py +0 -0
  173. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/render/renderer.py +0 -0
  174. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/render/rich_text.py +0 -0
  175. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/render/style.py +0 -0
  176. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/render/sweep.py +0 -0
  177. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/runner.py +0 -0
  178. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/terminal.py +0 -0
  179. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/testing/__init__.py +0 -0
  180. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/testing/harness.py +0 -0
  181. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/testing/snapshot.py +0 -0
  182. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/__init__.py +0 -0
  183. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/collapsible.py +0 -0
  184. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/completion.py +0 -0
  185. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/dialog.py +0 -0
  186. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/diff.py +0 -0
  187. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/list.py +0 -0
  188. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/list_page.py +0 -0
  189. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/markdown.py +0 -0
  190. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/progress.py +0 -0
  191. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/rich_text.py +0 -0
  192. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/status.py +0 -0
  193. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/table.py +0 -0
  194. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/tasks.py +0 -0
  195. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/text.py +0 -0
  196. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/textarea.py +0 -0
  197. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/tui/widgets/toast.py +0 -0
  198. {zettcode-0.1.6 → zettcode-0.1.7}/src/zettcode/update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zettcode
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: A focused terminal coding agent powered by zett-agent
5
5
  Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
6
  Project-URL: Issues, https://github.com/Chang-LeHung/zettcode/issues
@@ -21,13 +21,17 @@ Classifier: Programming Language :: Python :: 3.14
21
21
  Classifier: Typing :: Typed
22
22
  Requires-Python: >=3.10
23
23
  Requires-Dist: exceptiongroup<2,>=1.2; python_version < '3.11'
24
+ Requires-Dist: httpx<1,>=0.27
24
25
  Requires-Dist: pydantic<3,>=2
25
26
  Requires-Dist: pyperclip<2,>=1.9
26
27
  Requires-Dist: setproctitle<2,>=1.3.7; os_name == 'posix'
28
+ Requires-Dist: starlette<2,>=0.48
27
29
  Requires-Dist: tiktoken<1,>=0.12
28
30
  Requires-Dist: tomli<3,>=2; python_version < '3.11'
29
31
  Requires-Dist: truststore<1,>=0.10
32
+ Requires-Dist: uvicorn<1,>=0.35
30
33
  Requires-Dist: wcwidth<1,>=0.2
34
+ Requires-Dist: websockets<17,>=15
31
35
  Requires-Dist: zett-agent<0.2,>=0.1.15
32
36
  Description-Content-Type: text/markdown
33
37
 
@@ -29,6 +29,13 @@ dependencies = [
29
29
  "pydantic>=2,<3",
30
30
  "pyperclip>=1.9,<2",
31
31
  "setproctitle>=1.3.7,<2; os_name == 'posix'",
32
+ # The session broker is an ordinary web server: a Starlette application served by uvicorn. A session reaches it
33
+ # with httpx (to send) and websockets (to be reachable). The floors are the oldest versions the broker's tests
34
+ # were run against; all of them arrive through the runtime today and are declared so that is not luck.
35
+ "starlette>=0.48,<2",
36
+ "uvicorn>=0.35,<1",
37
+ "httpx>=0.27,<1",
38
+ "websockets>=15,<17",
32
39
  # The counter behind ``/context`` measures a request with it, and imports it
33
40
  # itself: the floor matches the runtime's, so neither resolver has to fight.
34
41
  "tiktoken>=0.12,<1",
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
7
7
 
8
8
  #: The one place the version is written. ``pyproject.toml`` reads it from here
9
9
  #: and the release workflow checks the tag against it, so a bump is one edit.
10
- __version__ = "0.1.6"
10
+ __version__ = "0.1.7"
11
11
 
12
12
  #: Where each public name lives; read by :func:`__getattr__` on first use.
13
13
  _EXPORTS = {
@@ -0,0 +1,54 @@
1
+ """What every part of the agent shares: the runtime it speaks to, and who and where it is.
2
+
3
+ The mixins in this package are split by what they are responsible for, and each one
4
+ inherits this class only to be type-checked against what the others rely on. The two
5
+ properties here are read by nearly all of them, which is why they live below all of them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from ..runtime import ZettCodeRuntime
13
+ from ..storage import SessionLocked
14
+
15
+
16
+ class AgentBase:
17
+ """The runtime, the workspace it works in, and the session the next turn belongs to."""
18
+
19
+ #: The runtime this agent speaks to; the one thing the application agent holds.
20
+ runtime: ZettCodeRuntime
21
+
22
+ @property
23
+ def workspace(self) -> Path:
24
+ """Return the workspace displayed in the header."""
25
+ return self.runtime.config.workspace
26
+
27
+ @property
28
+ def session_id(self) -> str:
29
+ """Return the session used for the next turn."""
30
+ return self.runtime.session_id
31
+
32
+ @property
33
+ def session_locked(self) -> bool:
34
+ """Return whether another process has the active session open, so that this one may only read it.
35
+
36
+ Every session is locked by the process that has it open (see :mod:`..storage.locks`). A session this
37
+ process started is always its own; one it resumed may be somebody else's, and then it is shown and
38
+ nothing is written to it: no turn, no title, no compaction.
39
+ """
40
+ return self.runtime.session_locked
41
+
42
+ def retake_session(self) -> bool:
43
+ """Try again to lock the active session; return whether this process holds it now."""
44
+ return self.runtime.retake_session()
45
+
46
+ def require_session(self) -> None:
47
+ """Refuse to go on when the active session is another process's.
48
+
49
+ Raises:
50
+ SessionLocked: The lock is not held. A ``ValueError``, which the shell shows as a line in the
51
+ conversation.
52
+ """
53
+ if self.session_locked:
54
+ raise SessionLocked()
@@ -80,9 +80,11 @@ class ContextMixin(AgentBase):
80
80
  worth replacing.
81
81
 
82
82
  Raises:
83
+ SessionLocked: Another process has this session open, and a checkpoint is a write.
83
84
  ValueError: When the newest checkpoint already covers the branch —
84
85
  a second pass would summarize nothing but the checkpoint.
85
86
  """
87
+ self.require_session()
86
88
  if self._compacted_at_the_head():
87
89
  raise ValueError("Already compacted; send a message before compacting again")
88
90
  await self.runtime.start()
@@ -23,6 +23,9 @@ class MentionPart:
23
23
  """
24
24
 
25
25
  token: str
26
+ #: The words the model reads in the token's place, when it is not to see the token itself. ``None``
27
+ #: leaves the token as typed; the typed text is what the transcript and the store keep either way.
28
+ says: str | None = None
26
29
 
27
30
 
28
31
  #: One piece of a user turn, in the order it was written: a run of text, or the
@@ -39,6 +42,29 @@ def plain_text_part(part: PromptPart) -> str:
39
42
  return ""
40
43
 
41
44
 
45
+ def model_text_part(part: PromptPart) -> str:
46
+ """Return the text one part is to the model: a reference that was resolved reads as what it says."""
47
+ if isinstance(part, MentionPart) and part.says is not None:
48
+ return part.says
49
+ return plain_text_part(part)
50
+
51
+
52
+ def mention_parts(parts: Sequence[PromptPart], mentions: MentionRegistry) -> tuple[PromptPart, ...]:
53
+ """Return a turn with each reference that has words of its own carrying them.
54
+
55
+ ``@work`` typed for a session that is running is not a word the model knows, so it is put in the
56
+ model's reading as ``session "work"`` and :func:`mention_hint` explains it. Anything else — a skill, a
57
+ file, a name nobody has — keeps the token as it was typed.
58
+ """
59
+ if not mentions.providers:
60
+ return tuple(parts)
61
+ resolved: list[PromptPart] = []
62
+ for part in parts:
63
+ says = mentions.rename_token(part.token) if isinstance(part, MentionPart) else None
64
+ resolved.append(MentionPart(part.token, says) if says is not None and isinstance(part, MentionPart) else part)
65
+ return tuple(resolved)
66
+
67
+
42
68
  def plain_text(parts: Sequence[PromptPart]) -> str:
43
69
  """Return a turn as the reader typed it, with its ``@`` tokens back in place."""
44
70
  return "".join(plain_text_part(part) for part in parts)
@@ -180,7 +180,11 @@ class SessionsMixin(ContextMixin):
180
180
  Args:
181
181
  title: Name to store; surrounding space is trimmed, and a blank or
182
182
  over-long name is rejected by the store.
183
+
184
+ Raises:
185
+ SessionLocked: Another process has this session open.
183
186
  """
187
+ self.require_session()
184
188
  await self.runtime.persistence.set_title(self.session_id, title)
185
189
  return title.strip()
186
190
 
@@ -199,6 +203,8 @@ class SessionsMixin(ContextMixin):
199
203
  """
200
204
  if self.runtime.persistence.session_title(session_id) is not None:
201
205
  return None
206
+ if session_id == self.session_id and self.session_locked:
207
+ return None # a nicety, and not one that is worth writing into a session that is not ours
202
208
  session = self.runtime.persistence.read(session_id)
203
209
  branch = session.branch()
204
210
  question = next(
@@ -12,9 +12,10 @@ from zett_agent.extensions.external import ExternalEvent
12
12
  from zett_agent.extensions.steering import STEERING_MESSAGE_EVENT_NAME
13
13
  from zett_agent.messages import ImageBytesSource, ImageContent, TextContent, UserMessage
14
14
 
15
+ from ..dialogue.relay import Relayed
15
16
  from ..dialogue.side import SIDE, question
16
17
  from .base import AgentBase
17
- from .prompt import MentionPart, PromptPart, carries_image, plain_text, plain_text_part
18
+ from .prompt import MentionPart, PromptPart, carries_image, model_text_part, plain_text
18
19
 
19
20
 
20
21
  class TurnsMixin(AgentBase):
@@ -30,6 +31,7 @@ class TurnsMixin(AgentBase):
30
31
  *,
31
32
  hint: str | None = None,
32
33
  side: bool = False,
34
+ relayed: Relayed | None = None,
33
35
  ) -> AsyncIterator[AgentEvent]:
34
36
  """Run one turn with the selected session, model, and reasoning effort.
35
37
 
@@ -43,14 +45,21 @@ class TurnsMixin(AgentBase):
43
45
  what was typed and a restored session shows it.
44
46
  side: Ask without letting the exchange join the conversation; see
45
47
  :mod:`zettcode.app.agent.side`.
48
+ relayed: The turn is a message another session sent. It is what the model reads
49
+ (with where it came from), and ``parts`` and ``hint`` are not used.
50
+
51
+ Raises:
52
+ SessionLocked: Another process has the session open. Checked before anything else, and a side
53
+ question is no exception: the store records it even though it never replays it.
46
54
  """
55
+ self.require_session()
47
56
  await self.runtime.start()
48
57
  client = self.runtime.started
49
58
  used = self.runtime.active_model.shown_name
50
59
  session_id = self.session_id
51
60
  try:
52
61
  async for event in client.stream(
53
- self.request(parts, hint=hint, side=side),
62
+ self.request(parts, hint=hint, side=side) if relayed is None else relayed.message(),
54
63
  config=AgentRunConfig(session_id=session_id),
55
64
  model=self.runtime.provider,
56
65
  reasoning_effort=self.runtime.effort,
@@ -105,11 +114,12 @@ class TurnsMixin(AgentBase):
105
114
  text = plain_text(parts)
106
115
  if hint is None:
107
116
  return text
108
- return UserMessage(content=f"{text}\n\n{hint}", attributes={"prompt": text})
117
+ said = "".join(model_text_part(part) for part in parts)
118
+ return UserMessage(content=f"{said}\n\n{hint}", attributes={"prompt": text})
109
119
  content: list[TextContent | ImageContent] = []
110
120
  for part in parts:
111
121
  if isinstance(part, (str, MentionPart)):
112
- text = plain_text_part(part)
122
+ text = model_text_part(part)
113
123
  if text:
114
124
  content.append(TextContent(text))
115
125
  continue
@@ -38,6 +38,7 @@ class EntryKind(StrEnum):
38
38
  THINKING: Reasoning, or a compaction's summary, with its own clock.
39
39
  TOOL: One tool call and its bounded result.
40
40
  CHANGES: The files a request changed, said once when it ends.
41
+ RELAY: A message that crossed between two sessions, in either direction.
41
42
  """
42
43
 
43
44
  WELCOME = "welcome"
@@ -50,6 +51,7 @@ class EntryKind(StrEnum):
50
51
  THINKING = "thinking"
51
52
  TOOL = "tool"
52
53
  CHANGES = "changes"
54
+ RELAY = "relay"
53
55
 
54
56
 
55
57
  class EntryStatus(StrEnum):
@@ -181,6 +183,13 @@ class WaitEntry(TimedEntry):
181
183
  kind: EntryKind = field(default=EntryKind.WAIT, init=False)
182
184
  title: str = "Processing"
183
185
  started_at: float
186
+ #: The clock reading of the last thing the agent said (a delta, a tool, a step), which is when the model
187
+ #: was last heard from; ``None`` until it has said anything.
188
+ heard_at: float | None = None
189
+ #: Seconds the model has said nothing while nothing else was running, brought up with the timers. Zero
190
+ #: while a tool, a reasoning row or a compaction is the thing being waited for: a ``pytest`` that takes two
191
+ #: minutes is not the model going quiet.
192
+ quiet: float = 0.0
184
193
  #: The line the request ended on, written when the row is settled.
185
194
  text: str = ""
186
195
 
@@ -289,6 +298,24 @@ class DeleteEntry(ToolEntry):
289
298
  path: str = ""
290
299
 
291
300
 
301
+ @dataclass(slots=True, kw_only=True)
302
+ class MessageEntry(ToolEntry):
303
+ """A message the agent sent to another session: who it was for, and every word of it.
304
+
305
+ The tool's own answer is one line (``Delivered to work …``); what the reader wants to see is what
306
+ was said, which the call's arguments hold and a one-line title cannot, so the entry keeps it and the
307
+ row shows it — a few lines folded, the whole message expanded.
308
+ """
309
+
310
+ to: str = ""
311
+ message: str = ""
312
+
313
+ @property
314
+ def foldable(self) -> bool:
315
+ """Return whether there is more to see once the call is over: what was said, or how it ended."""
316
+ return not self.running and bool(self.message or self.text)
317
+
318
+
292
319
  @dataclass(frozen=True, slots=True)
293
320
  class FileSummary:
294
321
  """What one request did to one file, in total.
@@ -316,3 +343,26 @@ class ChangesEntry(Entry):
316
343
 
317
344
  kind: EntryKind = field(default=EntryKind.CHANGES, init=False)
318
345
  files: tuple[FileSummary, ...] = ()
346
+
347
+
348
+ @dataclass(slots=True, kw_only=True)
349
+ class RelayEntry(Entry):
350
+ """A message between two sessions: one this session received, or one its reader sent.
351
+
352
+ Attributes:
353
+ peer: The other session's handle.
354
+ text: The message.
355
+ outgoing: This session sent it; otherwise it arrived.
356
+ by_agent: The other session's agent wrote it (received), or this session's reader chose to
357
+ send it with ``@`` (sent, so ``False``).
358
+ external: It came from a program that is not a ZettCode session, whose name nobody has checked.
359
+ note: What became of a sent message, such as ``queued``; empty for a received one.
360
+ """
361
+
362
+ kind: EntryKind = field(default=EntryKind.RELAY, init=False)
363
+ peer: str
364
+ text: str
365
+ outgoing: bool = False
366
+ by_agent: bool = True
367
+ external: bool = False
368
+ note: str = ""
@@ -37,7 +37,17 @@ from ....tui import (
37
37
  TextLine,
38
38
  Theme,
39
39
  )
40
- from ....tui.render import SweepSpan, display_width, highlight, inset_line, layout_rich_lines, sweep_spans, truncate
40
+ from ....tui.render import (
41
+ SweepSpan,
42
+ blend,
43
+ display_width,
44
+ highlight,
45
+ inset_line,
46
+ layout_rich_lines,
47
+ sweep_spans,
48
+ truncate,
49
+ wrap_words,
50
+ )
41
51
  from ....tui.widgets.markdown import MarkdownSource
42
52
  from ...brand import LOGO_LINES
43
53
  from .entries import (
@@ -49,8 +59,10 @@ from .entries import (
49
59
  FileChangeEntry,
50
60
  FileSummary,
51
61
  MarkdownEntry,
62
+ MessageEntry,
52
63
  NoticeLevel,
53
64
  ReadEntry,
65
+ RelayEntry,
54
66
  TextEntry,
55
67
  ThinkingEntry,
56
68
  ToolEntry,
@@ -69,6 +81,7 @@ from .rows import (
69
81
  duration_text,
70
82
  elapsed_text,
71
83
  moving,
84
+ quiet_text,
72
85
  sweep_column,
73
86
  terminal_safe,
74
87
  )
@@ -97,6 +110,8 @@ TOOL_ROLES = {
97
110
  "todo_write": "plan",
98
111
  "read_skill": "read",
99
112
  "task": "subagent",
113
+ "send_message": "message",
114
+ "list_sessions": "message",
100
115
  }
101
116
 
102
117
 
@@ -184,11 +199,27 @@ class Presenter(Generic[E], ABC):
184
199
  return entry.block
185
200
 
186
201
  @staticmethod
187
- def text_lines(text: str, width: int, style: Style, *, indent: int = 0) -> list[TextLine]:
188
- """Wrap text with the same layout used by RichText and Markdown."""
189
- source = tuple(TextLine((Span(line, style),)) for line in text.split("\n"))
202
+ def text_lines(text: str, width: int, style: Style, *, indent: int = 0, words: bool = False) -> list[TextLine]:
203
+ """Wrap text with the same layout used by RichText and Markdown.
204
+
205
+ Args:
206
+ text: What to wrap; each newline starts a new row.
207
+ width: Columns available.
208
+ style: The ink the text is drawn in.
209
+ indent: Columns left empty at the start of every row.
210
+ words: Break between words and not mid-word. For a sentence a reader is meant to read, such as an
211
+ error naming a command to try; left off, a row breaks at the column, as code and output must.
212
+ """
190
213
  margin = min(indent, max(0, width - 1))
191
- return [inset_line(row, margin) for row in layout_rich_lines(source, max(1, width - margin))]
214
+ room = max(1, width - margin)
215
+ if words:
216
+ rows = [piece for line in text.split("\n") for piece in wrap_words(line, room)]
217
+ # A character wider than the whole row (a wide glyph in a column or two) cannot be placed in it;
218
+ # the layout that wraps by column cuts such a row to the budget, and so does this.
219
+ fitted = layout_rich_lines(tuple(TextLine((Span(row, style),)) for row in rows), room, wrap=False)
220
+ return [inset_line(row, margin) for row in fitted]
221
+ source = tuple(TextLine((Span(line, style),)) for line in text.split("\n"))
222
+ return [inset_line(row, margin) for row in layout_rich_lines(source, room)]
192
223
 
193
224
 
194
225
  class WelcomePresenter(Presenter[TextEntry]):
@@ -236,7 +267,7 @@ class NoticePresenter(Presenter[TextEntry]):
236
267
  def lines(self, entry: TextEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
237
268
  """Leave a blank separator and let the transcript gutter add the inset."""
238
269
  colour = theme.error if entry.level == NoticeLevel.ERROR else theme.muted
239
- return [TextLine(), *self.text_lines(entry.text, width, Style(foreground=colour))]
270
+ return [TextLine(), *self.text_lines(entry.text, width, Style(foreground=colour), words=True)]
240
271
 
241
272
 
242
273
  class AnnouncementPresenter(Presenter[TextEntry]):
@@ -309,6 +340,79 @@ class UserPresenter(Presenter[TextEntry]):
309
340
  return lines
310
341
 
311
342
 
343
+ def relay_detail(theme: Theme) -> str:
344
+ """Return the colour of the quiet words on a message between sessions (``delivered``, ``queued``).
345
+
346
+ The surface is a little lighter than the reader's own, and the usual quiet text would be too close to
347
+ it, so the words are taken halfway to the body colour: still quieter than the message, and readable.
348
+ """
349
+ return blend(theme.subtle, theme.text, 0.5)
350
+
351
+
352
+ class RelayPresenter(Presenter[RelayEntry]):
353
+ """Draw a message between two sessions the way the reader's own message is drawn, on a surface of its own.
354
+
355
+ Same arrow, same text column, same filled rows as what the reader types — it is part of the
356
+ conversation — but on a different background, with a line above the text that says whose it is::
357
+
358
+ ← agent of session fix-the-parser
359
+ › The tokenizer change is merged; can you rerun the parser tests?
360
+
361
+ → to session fix-the-parser · queued
362
+ › yes, on it
363
+
364
+ The header is the one place colour says who spoke: the message colour (mauve) for another session's
365
+ *agent* (another agent, as a delegated child is), the warning yellow for another session's reader,
366
+ and the accent for this reader's own message to it.
367
+ """
368
+
369
+ kind = EntryKind.RELAY
370
+ gutter = True
371
+
372
+ def lines(self, entry: RelayEntry, width: int, theme: Theme, frame: int) -> list[TextLine]:
373
+ """Return the header and the wrapped message on the surface, every row filled to the edge."""
374
+ if entry.outgoing:
375
+ tone = theme.accent
376
+ elif entry.external:
377
+ tone = theme.warning
378
+ elif entry.by_agent:
379
+ tone = theme.tools.message
380
+ else:
381
+ tone = theme.warning
382
+ style = Style(foreground=theme.text, background=theme.surface_peer)
383
+ margin = min(CONTENT_INDENT, max(0, width - 1))
384
+ pad = " " * display_width(PROMPT)
385
+ peer = terminal_safe(entry.peer).replace("\n", " ")
386
+ if entry.outgoing:
387
+ label = f"\u2192 to session {peer}"
388
+ detail = f" {SEPARATOR} {entry.note}" if entry.note else ""
389
+ else:
390
+ label = (
391
+ f"\u2190 external source {peer}"
392
+ if entry.external
393
+ else f"\u2190 {'agent' if entry.by_agent else 'user'} of session {peer}"
394
+ )
395
+ detail = ""
396
+
397
+ def row(*spans: Span) -> TextLine:
398
+ used = sum(display_width(span.text) for span in spans)
399
+ return TextLine((*spans, Span(" " * max(0, width - used), style)))
400
+
401
+ head = truncate(f"{pad} {label}", width)
402
+ tail = truncate(detail, max(0, width - display_width(head)))
403
+ header = row(
404
+ Span(head, Style(foreground=tone, background=theme.surface_peer)),
405
+ Span(tail, Style(foreground=relay_detail(theme), background=theme.surface_peer)),
406
+ )
407
+ blank = row()
408
+ lines = [TextLine(), blank, header]
409
+ for index, text in enumerate(self.text_lines(terminal_safe(entry.text), max(1, width - margin), style)):
410
+ prefix = f"{PROMPT} " if index == 0 else f"{pad} "
411
+ lines.append(row(Span(truncate(f"{prefix}{text.text}", width), style)))
412
+ lines.append(blank)
413
+ return lines
414
+
415
+
312
416
  class WaitPresenter(Presenter[WaitEntry]):
313
417
  """Draw the pinned wait row: a sweep while it runs, a line once it has ended.
314
418
 
@@ -349,8 +453,16 @@ class WaitPresenter(Presenter[WaitEntry]):
349
453
  ]
350
454
  spans = list(_running_label(entry.title, theme, frame))
351
455
  if entry.duration is not None:
352
- aside = f" ({elapsed_text(entry.duration)} {SEPARATOR} {STOP_HINT})"
353
- spans.append(Span(aside, Style(foreground=theme.muted)))
456
+ quiet = quiet_text(entry.quiet)
457
+ said = f"{elapsed_text(entry.duration)} {SEPARATOR} "
458
+ if quiet is not None:
459
+ # In the warning ink, apart from the quiet aside around it: this is the one thing in the row
460
+ # that says something may be wrong, and a reader who has been waiting is looking for it.
461
+ spans.append(Span(f" ({said}", Style(foreground=theme.muted)))
462
+ spans.append(Span(quiet, Style(foreground=theme.warning)))
463
+ spans.append(Span(f" {SEPARATOR} {STOP_HINT})", Style(foreground=theme.muted)))
464
+ else:
465
+ spans.append(Span(f" ({said}{STOP_HINT})", Style(foreground=theme.muted)))
354
466
  return [TextLine(), layout_rich_lines((TextLine(tuple(spans)),), width, wrap=False)[0]]
355
467
 
356
468
 
@@ -465,6 +577,44 @@ class ToolPresenter(Presenter[T]):
465
577
  return lines
466
578
 
467
579
 
580
+ class SessionMessagePresenter(ToolPresenter[MessageEntry]):
581
+ """A message the agent sent to another session: what it said, then how it ended.
582
+
583
+ Folded, the row shows the first lines of the message and the one-line outcome; expanded, every
584
+ line of the message. The message comes first because it is the substance of the call; the outcome
585
+ (``Delivered to work …``, or why it was not) is a line of its own underneath, in the colour of the
586
+ result — muted when it went, the error colour when it did not.
587
+ """
588
+
589
+ entry_type = MessageEntry
590
+
591
+ def body_lines(self, entry: MessageEntry, width: int, theme: Theme, muted: Style) -> list[TextLine]:
592
+ """Return the message, bounded as any tool output is, and the outcome under it."""
593
+ if entry.running:
594
+ return []
595
+ inner = max(1, width - CONTENT_INDENT * 2)
596
+ limit = TOOL_EXPANDED_ROWS if entry.expanded else TOOL_PREVIEW_ROWS
597
+ detail = Style(foreground=theme.subtle)
598
+ gutter = Style(foreground=theme.muted)
599
+ lines: list[TextLine] = []
600
+ rows, omitted = bounded_rows(entry.message, inner, limit) if entry.message else ([], 0)
601
+ for index, row in enumerate(rows):
602
+ lead = f"{' ' * CONTENT_INDENT}\u2514 " if index == 0 else " " * (CONTENT_INDENT * 2)
603
+ lines.append(TextLine((Span(lead, gutter), Span(row, detail))))
604
+ if omitted:
605
+ lines.append(TextLine((Span(f"{' ' * (CONTENT_INDENT * 2)}{ELLIPSIS} {omitted} more rows", muted),)))
606
+ if entry.text:
607
+ ended = Style(foreground=theme.error if entry.status is EntryStatus.FAILED else theme.muted)
608
+ # An arrow, so the answer is not read as the last line of the message above it.
609
+ outcome, more = bounded_rows(entry.text, max(1, inner - 2), 3 if entry.expanded else 1)
610
+ for index, row in enumerate(outcome):
611
+ lead = f"{' ' * CONTENT_INDENT}\u2192 " if index == 0 else " " * (CONTENT_INDENT * 2)
612
+ lines.append(TextLine((Span(f"{lead}{row}", ended),)))
613
+ if more and not entry.expanded:
614
+ lines[-1] = TextLine((Span(f"{lines[-1].text} {ELLIPSIS}", ended),))
615
+ return lines
616
+
617
+
468
618
  class ReadPresenter(ToolPresenter[ReadEntry]):
469
619
  """A file that was read: the body is highlighted as the language the file is in."""
470
620
 
@@ -704,9 +854,11 @@ DEFAULT_PRESENTERS = Presenters(
704
854
  ThinkingPresenter(),
705
855
  ToolPresenter(),
706
856
  ChangesPresenter(),
857
+ RelayPresenter(),
707
858
  ReadPresenter(),
708
859
  WritePresenter(),
709
860
  EditPresenter(),
861
+ SessionMessagePresenter(),
710
862
  )
711
863
  )
712
864
 
@@ -11,6 +11,7 @@ from zett_agent.extensions.shell_approval import SHELL_APPROVAL_EVENT_NAME
11
11
  from zett_agent.messages import ToolMessage
12
12
 
13
13
  from ..dialogue.ask import AskUserQuestion, question_from
14
+ from ..dialogue.relay import Relayed
14
15
  from ..extensions.mcp_notice import MCP_NOTICE_EVENT_NAME, notice_from
15
16
  from ..extensions.usage import USAGE_EVENT_NAME
16
17
  from .entries import EntryStatus
@@ -81,14 +82,27 @@ class TranscriptProjector(AgentEventDispatcher):
81
82
  self.on_steering_started = on_steering_started
82
83
  self.on_steering_interrupted = on_steering_interrupted
83
84
 
84
- def begin_turn(self, prompt: str, *, side: bool = False) -> None:
85
+ def begin_turn(self, prompt: str, *, side: bool = False, relayed: Relayed | None = None) -> None:
85
86
  """Echo the prompt into the transcript before the run starts.
86
87
 
87
88
  Args:
88
89
  prompt: What the reader asked.
89
90
  side: The question is a side one, which the transcript shows as such.
91
+ relayed: The turn answers a message another session sent, which the transcript shows as that.
90
92
  """
91
- self.transcript.begin_turn(prompt, side=side)
93
+ if relayed is None:
94
+ self.transcript.begin_turn(prompt, side=side)
95
+ else:
96
+ self.transcript.begin_turn(prompt, side=side, relayed=relayed)
97
+
98
+ async def dispatch(self, event: AgentEvent) -> None:
99
+ """Note that the agent spoke, then hand the event to the callback that draws it.
100
+
101
+ Every event ends a silence, whichever it is: the waiting row counts how long it has been since the model
102
+ last said anything, and has to be told each time it does.
103
+ """
104
+ self.transcript.heard()
105
+ await super().dispatch(event)
92
106
 
93
107
  async def on_compaction_started_event(self, event: AgentEvent) -> None:
94
108
  """Open the compaction row; the summary streams into it as it arrives."""
@@ -32,7 +32,8 @@ from pathlib import Path
32
32
 
33
33
  from ....tui import ELLIPSIS, SEPARATOR
34
34
  from ....tui.render import GENERIC, language_for
35
- from .entries import DeleteEntry, EditEntry, ReadEntry, ToolEntry, WriteEntry
35
+ from .entries import DeleteEntry, EditEntry, MessageEntry, ReadEntry, ToolEntry, WriteEntry
36
+ from .rows import limit_output
36
37
 
37
38
  #: Output kinds the chain shapes; a link claims one or more of them.
38
39
  TOOL = "tool"
@@ -539,6 +540,39 @@ class TaskTool(ToolHandler):
539
540
  return output
540
541
 
541
542
 
543
+ _UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
544
+
545
+
546
+ class MessageTool(ToolHandler):
547
+ """``send_message``: a message to another session, kept whole so the row can show it."""
548
+
549
+ names = frozenset({"send_message"})
550
+
551
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
552
+ """Say who it is for; an id is shortened to its end, which is how a session is named without a title."""
553
+ return ToolRow(f"Message to {self._recipient(argument(arguments, 'to', '(unknown)'))}")
554
+
555
+ @staticmethod
556
+ def _recipient(to: str) -> str:
557
+ """Return the recipient as a reader can use it: a name as it is, an id by its last characters."""
558
+ return f"session {to[-6:]}" if _UUID.fullmatch(to) else to
559
+
560
+ def open_tool(
561
+ self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
562
+ ) -> ToolEntry:
563
+ """A message row, which holds the text that was sent."""
564
+ message = arguments.get("message")
565
+ return MessageEntry(
566
+ id=id,
567
+ call_id=call_id,
568
+ tool=name,
569
+ title=self.describe(name, arguments).title,
570
+ to=argument(arguments, "to"),
571
+ message=limit_output(message) if isinstance(message, str) else "",
572
+ started_at=started_at,
573
+ )
574
+
575
+
542
576
  class FallbackTool(ToolHandler):
543
577
  """Anything no other link claims: its name and flat arguments, never JSON."""
544
578
 
@@ -610,6 +644,7 @@ DEFAULT_RENDERERS = Renderers(
610
644
  TodoTool(),
611
645
  SkillTool(),
612
646
  TaskTool(),
647
+ MessageTool(),
613
648
  FallbackTool(),
614
649
  ReasoningHandler(),
615
650
  AnswerHandler(),