milo-cli 0.1.1__tar.gz → 0.2.1__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 (119) hide show
  1. {milo_cli-0.1.1 → milo_cli-0.2.1}/PKG-INFO +2 -2
  2. {milo_cli-0.1.1 → milo_cli-0.2.1}/pyproject.toml +44 -3
  3. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/__init__.py +28 -1
  4. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_child.py +35 -25
  5. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_command_defs.py +8 -0
  6. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_types.py +159 -1
  7. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/commands.py +149 -41
  8. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/context.py +42 -0
  9. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/gateway.py +7 -4
  10. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/groups.py +42 -0
  11. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/help.py +2 -0
  12. milo_cli-0.2.1/src/milo/pipeline.py +511 -0
  13. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/schema.py +75 -14
  14. milo_cli-0.2.1/src/milo/state.py +1010 -0
  15. milo_cli-0.2.1/src/milo/templates/__init__.py +70 -0
  16. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/components/_defs.kida +87 -10
  17. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/components/help_page.kida +14 -6
  18. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/error.kida +4 -0
  19. milo_cli-0.2.1/src/milo/templates/form.kida +10 -0
  20. milo_cli-0.2.1/src/milo/templates/help.kida +30 -0
  21. milo_cli-0.2.1/src/milo/theme.py +214 -0
  22. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/PKG-INFO +2 -2
  23. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/SOURCES.txt +4 -0
  24. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/requires.txt +1 -1
  25. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_ai_native.py +46 -1
  26. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_child.py +96 -0
  27. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_compat.py +1 -1
  28. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_components.py +114 -0
  29. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_context.py +50 -0
  30. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_dev.py +2 -2
  31. milo_cli-0.2.1/tests/test_effects.py +1819 -0
  32. milo_cli-0.2.1/tests/test_effects_stress.py +369 -0
  33. milo_cli-0.2.1/tests/test_gateway.py +467 -0
  34. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_groups.py +139 -0
  35. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_lazy.py +280 -0
  36. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_milo_init.py +3 -1
  37. milo_cli-0.2.1/tests/test_pipeline.py +614 -0
  38. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_schema_v2.py +130 -0
  39. milo_cli-0.2.1/tests/test_theme.py +252 -0
  40. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_types.py +4 -1
  41. milo_cli-0.1.1/src/milo/pipeline.py +0 -278
  42. milo_cli-0.1.1/src/milo/state.py +0 -394
  43. milo_cli-0.1.1/src/milo/templates/__init__.py +0 -38
  44. milo_cli-0.1.1/src/milo/templates/form.kida +0 -8
  45. milo_cli-0.1.1/src/milo/templates/help.kida +0 -13
  46. milo_cli-0.1.1/tests/test_effects.py +0 -176
  47. milo_cli-0.1.1/tests/test_pipeline.py +0 -299
  48. {milo_cli-0.1.1 → milo_cli-0.2.1}/README.md +0 -0
  49. {milo_cli-0.1.1 → milo_cli-0.2.1}/setup.cfg +0 -0
  50. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_cli_help.py +0 -0
  51. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_compat.py +0 -0
  52. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_errors.py +0 -0
  53. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_jsonrpc.py +0 -0
  54. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_mcp_router.py +0 -0
  55. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/_protocols.py +0 -0
  56. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/app.py +0 -0
  57. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/cli.py +0 -0
  58. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/completions.py +0 -0
  59. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/config.py +0 -0
  60. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/dev.py +0 -0
  61. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/doctor.py +0 -0
  62. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/flow.py +0 -0
  63. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/form.py +0 -0
  64. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/input/__init__.py +0 -0
  65. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/input/_platform.py +0 -0
  66. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/input/_reader.py +0 -0
  67. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/input/_sequences.py +0 -0
  68. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/llms.py +0 -0
  69. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/mcp.py +0 -0
  70. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/middleware.py +0 -0
  71. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/observability.py +0 -0
  72. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/output.py +0 -0
  73. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/plugins.py +0 -0
  74. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/py.typed +0 -0
  75. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/reducers.py +0 -0
  76. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/registry.py +0 -0
  77. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/streaming.py +0 -0
  78. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/components/command_list.kida +0 -0
  79. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/field_confirm.kida +0 -0
  80. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/field_select.kida +0 -0
  81. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/field_text.kida +0 -0
  82. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/templates/progress.kida +0 -0
  83. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/__init__.py +0 -0
  84. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/_mcp.py +0 -0
  85. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/_record.py +0 -0
  86. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/_replay.py +0 -0
  87. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/testing/_snapshot.py +0 -0
  88. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo/version_check.py +0 -0
  89. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/dependency_links.txt +0 -0
  90. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/entry_points.txt +0 -0
  91. {milo_cli-0.1.1 → milo_cli-0.2.1}/src/milo_cli.egg-info/top_level.txt +0 -0
  92. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_app.py +0 -0
  93. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_bubbletea_patterns.py +0 -0
  94. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_cli.py +0 -0
  95. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_commands_middleware.py +0 -0
  96. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_completions.py +0 -0
  97. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_config.py +0 -0
  98. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_doctor.py +0 -0
  99. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_errors.py +0 -0
  100. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_flow.py +0 -0
  101. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_form.py +0 -0
  102. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_help.py +0 -0
  103. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_input.py +0 -0
  104. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_mcp_prompts.py +0 -0
  105. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_mcp_resources.py +0 -0
  106. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_mcp_router.py +0 -0
  107. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_middleware.py +0 -0
  108. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_mount.py +0 -0
  109. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_observability.py +0 -0
  110. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_plugins.py +0 -0
  111. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_protocols.py +0 -0
  112. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_reducers.py +0 -0
  113. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_registry_v2.py +0 -0
  114. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_state.py +0 -0
  115. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_streaming.py +0 -0
  116. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_templates.py +0 -0
  117. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_testing.py +0 -0
  118. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_testing_mcp.py +0 -0
  119. {milo_cli-0.1.1 → milo_cli-0.2.1}/tests/test_version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: milo-cli
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: Template-driven CLI applications for free-threaded Python
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://lbliii.github.io/milo-cli/
@@ -18,7 +18,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.14
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: kida-templates>=0.3.0
21
+ Requires-Dist: kida-templates>=0.4.0
22
22
  Provides-Extra: docs
23
23
  Requires-Dist: bengal>=0.2.6; extra == "docs"
24
24
  Provides-Extra: yaml
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "milo-cli"
7
- version = "0.1.1"
7
+ version = "0.2.1"
8
8
  description = "Template-driven CLI applications for free-threaded Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
11
11
  license = "MIT"
12
- dependencies = ["kida-templates>=0.3.0"]
12
+ dependencies = ["kida-templates>=0.4.0"]
13
13
  keywords = ["cli", "terminal", "forms", "free-threading", "template", "elm"]
14
14
  classifiers = [
15
15
  "Development Status :: 3 - Alpha",
@@ -129,7 +129,46 @@ ignore = [
129
129
  "src/milo/version_check.py" = ["S110", "S310"] # best-effort cache reads + known PyPI URL
130
130
  "src/milo/mcp.py" = ["ARG002"] # MCP protocol methods require params signature
131
131
  "src/milo/gateway.py" = ["ARG002", "S110"] # MCP protocol methods + best-effort discovery
132
- "benchmarks/**/*.py" = ["T201"] # benchmarks may use print for diagnostics
132
+ "benchmarks/**/*.py" = ["T201", "ARG", "S101"] # benchmarks: print, unused args, assert
133
+
134
+ [tool.towncrier]
135
+ directory = "changelog.d"
136
+ filename = "CHANGELOG.md"
137
+ package = "milo"
138
+ package_dir = "src"
139
+ title_format = "## {version} — {project_date}"
140
+ issue_format = "[#{issue}](https://github.com/lbliii/milo-cli/issues/{issue})"
141
+ underlines = ["", "", ""]
142
+
143
+ [[tool.towncrier.type]]
144
+ directory = "added"
145
+ name = "Added"
146
+ showcontent = true
147
+
148
+ [[tool.towncrier.type]]
149
+ directory = "changed"
150
+ name = "Changed"
151
+ showcontent = true
152
+
153
+ [[tool.towncrier.type]]
154
+ directory = "deprecated"
155
+ name = "Deprecated"
156
+ showcontent = true
157
+
158
+ [[tool.towncrier.type]]
159
+ directory = "removed"
160
+ name = "Removed"
161
+ showcontent = true
162
+
163
+ [[tool.towncrier.type]]
164
+ directory = "fixed"
165
+ name = "Fixed"
166
+ showcontent = true
167
+
168
+ [[tool.towncrier.type]]
169
+ directory = "security"
170
+ name = "Security"
171
+ showcontent = true
133
172
 
134
173
  [dependency-groups]
135
174
  dev = [
@@ -145,4 +184,6 @@ dev = [
145
184
  "ruff>=0.15.1",
146
185
  # Pre-commit
147
186
  "pre-commit>=4.0.0",
187
+ # Changelog
188
+ "towncrier>=24.0",
148
189
  ]
@@ -26,6 +26,12 @@ def __getattr__(name: str):
26
26
  "Fork": "_types",
27
27
  "Delay": "_types",
28
28
  "Retry": "_types",
29
+ "Timeout": "_types",
30
+ "TryCall": "_types",
31
+ "Race": "_types",
32
+ "All": "_types",
33
+ "Take": "_types",
34
+ "Debounce": "_types",
29
35
  "Cmd": "_types",
30
36
  "Batch": "_types",
31
37
  "Sequence": "_types",
@@ -48,6 +54,8 @@ def __getattr__(name: str):
48
54
  "format_render_error": "_errors",
49
55
  # State
50
56
  "Store": "state",
57
+ "SagaContext": "state",
58
+ "EffectResult": "state",
51
59
  "combine_reducers": "state",
52
60
  # App
53
61
  "App": "app",
@@ -84,8 +92,10 @@ def __getattr__(name: str):
84
92
  # Pipeline
85
93
  "Pipeline": "pipeline",
86
94
  "Phase": "pipeline",
95
+ "PhasePolicy": "pipeline",
87
96
  "PipelineState": "pipeline",
88
97
  "PhaseStatus": "pipeline",
98
+ "CycleError": "pipeline",
89
99
  # Plugins
90
100
  "HookRegistry": "plugins",
91
101
  "function_to_schema": "schema",
@@ -97,6 +107,11 @@ def __getattr__(name: str):
97
107
  "Le": "schema",
98
108
  "Pattern": "schema",
99
109
  "Description": "schema",
110
+ # Theme
111
+ "ThemeStyle": "theme",
112
+ "ThemeProxy": "theme",
113
+ "DEFAULT_THEME": "theme",
114
+ "make_style_filter": "theme",
100
115
  "format_output": "output",
101
116
  "write_output": "output",
102
117
  "generate_llms_txt": "llms",
@@ -139,11 +154,13 @@ def _Py_mod_gil() -> int: # noqa: N802
139
154
  return 0
140
155
 
141
156
 
142
- __version__ = "0.1.1"
157
+ __version__ = "0.2.1"
143
158
  __all__ = [
144
159
  "BUILTIN_ACTIONS",
145
160
  "CLI",
161
+ "DEFAULT_THEME",
146
162
  "Action",
163
+ "All",
147
164
  "App",
148
165
  "AppError",
149
166
  "AppStatus",
@@ -157,6 +174,8 @@ __all__ = [
157
174
  "ConfigError",
158
175
  "ConfigSpec",
159
176
  "Context",
177
+ "CycleError",
178
+ "Debounce",
160
179
  "Delay",
161
180
  "Description",
162
181
  "DevServer",
@@ -192,6 +211,7 @@ __all__ = [
192
211
  "MinLen",
193
212
  "Pattern",
194
213
  "Phase",
214
+ "PhasePolicy",
195
215
  "PhaseStatus",
196
216
  "Pipeline",
197
217
  "PipelineError",
@@ -201,6 +221,7 @@ __all__ = [
201
221
  "PromptDef",
202
222
  "Put",
203
223
  "Quit",
224
+ "Race",
204
225
  "ReducerResult",
205
226
  "RenderTarget",
206
227
  "RequestLog",
@@ -213,8 +234,13 @@ __all__ = [
213
234
  "SpecialKey",
214
235
  "StateError",
215
236
  "Store",
237
+ "Take",
238
+ "ThemeProxy",
239
+ "ThemeStyle",
216
240
  "TickCmd",
241
+ "Timeout",
217
242
  "Transition",
243
+ "TryCall",
218
244
  "VersionInfo",
219
245
  "ViewState",
220
246
  "check_version",
@@ -231,6 +257,7 @@ __all__ = [
231
257
  "get_context",
232
258
  "install_completions",
233
259
  "make_form_reducer",
260
+ "make_style_filter",
234
261
  "quit_on",
235
262
  "render_html",
236
263
  "run",
@@ -9,8 +9,6 @@ import threading
9
9
  import time
10
10
  from typing import Any
11
11
 
12
- _READ_TIMEOUT = 30.0 # seconds
13
-
14
12
 
15
13
  class ChildProcess:
16
14
  """A persistent child process that speaks JSON-RPC on stdin/stdout.
@@ -25,10 +23,12 @@ class ChildProcess:
25
23
  command: list[str],
26
24
  *,
27
25
  idle_timeout: float = 300.0,
26
+ request_timeout: float = 30.0,
28
27
  ) -> None:
29
28
  self.name = name
30
29
  self.command = command
31
30
  self.idle_timeout = idle_timeout
31
+ self.request_timeout = request_timeout
32
32
  self._proc: subprocess.Popen[str] | None = None
33
33
  self._lock = threading.Lock()
34
34
  self._last_use = time.monotonic()
@@ -72,8 +72,13 @@ class ChildProcess:
72
72
  self._spawn()
73
73
  self._ensure_initialized()
74
74
 
75
- def send_call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
76
- """Send a JSON-RPC request and return the result. Thread-safe."""
75
+ def send_call(
76
+ self, method: str, params: dict[str, Any], *, timeout: float | None = None
77
+ ) -> dict[str, Any]:
78
+ """Send a JSON-RPC request and return the result. Thread-safe.
79
+
80
+ *timeout* overrides the instance ``request_timeout`` for this call.
81
+ """
77
82
  with self._lock:
78
83
  if self._proc is None or self._proc.poll() is not None:
79
84
  self._spawn()
@@ -88,7 +93,8 @@ class ChildProcess:
88
93
  "params": params,
89
94
  }
90
95
  self._write_line(json.dumps(request))
91
- response_line = self._read_line()
96
+ effective_timeout = timeout if timeout is not None else self.request_timeout
97
+ response_line = self._read_line(timeout=effective_timeout)
92
98
  self._last_use = time.monotonic()
93
99
 
94
100
  if not response_line:
@@ -115,19 +121,25 @@ class ChildProcess:
115
121
  def kill(self) -> None:
116
122
  """Kill the child process."""
117
123
  with self._lock:
118
- if self._proc is not None:
119
- try:
120
- self._proc.terminate()
121
- self._proc.wait(timeout=5)
122
- except ProcessLookupError:
123
- pass
124
- except subprocess.TimeoutExpired:
125
- import contextlib
126
-
127
- with contextlib.suppress(ProcessLookupError):
128
- self._proc.kill()
129
- self._proc = None
130
- self._initialized = False
124
+ self._graceful_kill()
125
+
126
+ def _graceful_kill(self) -> None:
127
+ """SIGTERM with grace period, then SIGKILL if needed.
128
+
129
+ Must be called while holding ``self._lock``.
130
+ """
131
+ if self._proc is None:
132
+ return
133
+ try:
134
+ self._proc.terminate() # SIGTERM
135
+ self._proc.wait(timeout=5) # Grace period
136
+ except ProcessLookupError:
137
+ pass # Already dead
138
+ except subprocess.TimeoutExpired:
139
+ with contextlib.suppress(ProcessLookupError):
140
+ self._proc.kill() # SIGKILL
141
+ self._proc = None
142
+ self._initialized = False
131
143
 
132
144
  def _write_line(self, line: str) -> None:
133
145
  """Write a line to the child's stdin."""
@@ -136,12 +148,13 @@ class ChildProcess:
136
148
  self._proc.stdin.write(line + "\n")
137
149
  self._proc.stdin.flush()
138
150
 
139
- def _read_line(self) -> str:
151
+ def _read_line(self, *, timeout: float | None = None) -> str:
140
152
  """Read a line from the child's stdout with timeout.
141
153
 
142
154
  Uses a background thread so we can enforce a deadline even
143
155
  when the child blocks without writing a newline.
144
156
  """
157
+ effective_timeout = timeout if timeout is not None else self.request_timeout
145
158
  assert self._proc is not None
146
159
  assert self._proc.stdout is not None
147
160
  result: list[str] = []
@@ -150,12 +163,9 @@ class ChildProcess:
150
163
  daemon=True,
151
164
  )
152
165
  reader.start()
153
- reader.join(timeout=_READ_TIMEOUT)
166
+ reader.join(timeout=effective_timeout)
154
167
  if reader.is_alive():
155
- # Timed out — kill the child to unblock the reader thread
156
- with contextlib.suppress(OSError):
157
- self._proc.kill()
158
- self._proc = None
159
- self._initialized = False
168
+ # Timed out — graceful shutdown: SIGTERM grace SIGKILL
169
+ self._graceful_kill()
160
170
  return ""
161
171
  return result[0].strip() if result else ""
@@ -59,6 +59,8 @@ class CommandDef:
59
59
  """If non-empty, prompt for confirmation before running."""
60
60
  annotations: dict[str, Any] = field(default_factory=dict)
61
61
  """MCP tool annotations (readOnlyHint, destructiveHint, etc.)."""
62
+ display_result: bool = True
63
+ """If False, suppress plain-format output (return value still available for --format json)."""
62
64
 
63
65
 
64
66
  class LazyCommandDef:
@@ -80,6 +82,7 @@ class LazyCommandDef:
80
82
  "annotations",
81
83
  "confirm",
82
84
  "description",
85
+ "display_result",
83
86
  "examples",
84
87
  "hidden",
85
88
  "import_path",
@@ -100,6 +103,7 @@ class LazyCommandDef:
100
103
  examples: tuple[dict[str, Any], ...] | list[dict[str, Any]] = (),
101
104
  confirm: str = "",
102
105
  annotations: dict[str, Any] | None = None,
106
+ display_result: bool = True,
103
107
  ) -> None:
104
108
  self.name = name
105
109
  self.description = description
@@ -110,6 +114,7 @@ class LazyCommandDef:
110
114
  self.examples = tuple(examples)
111
115
  self.confirm = confirm
112
116
  self.annotations = annotations or {}
117
+ self.display_result = display_result
113
118
  self._schema = schema
114
119
  self._resolved: CommandDef | None = None
115
120
  self._lock = threading.Lock()
@@ -159,6 +164,7 @@ class LazyCommandDef:
159
164
  examples=self.examples,
160
165
  confirm=self.confirm,
161
166
  annotations=self.annotations,
167
+ display_result=self.display_result,
162
168
  )
163
169
  return self._resolved
164
170
 
@@ -185,6 +191,7 @@ def _make_command_def(
185
191
  examples: tuple[dict[str, Any], ...] = (),
186
192
  confirm: str = "",
187
193
  annotations: dict[str, Any] | None = None,
194
+ display_result: bool = True,
188
195
  ) -> CommandDef:
189
196
  """Build a CommandDef from a function and decorator kwargs."""
190
197
  from milo.schema import function_to_schema
@@ -204,6 +211,7 @@ def _make_command_def(
204
211
  examples=examples,
205
212
  confirm=confirm,
206
213
  annotations=annotations or {},
214
+ display_result=display_result,
207
215
  )
208
216
 
209
217
 
@@ -80,7 +80,10 @@ BUILTIN_ACTIONS: frozenset[str] = frozenset(
80
80
  "@@PHASE_START",
81
81
  "@@PHASE_COMPLETE",
82
82
  "@@PHASE_FAILED",
83
+ "@@PHASE_SKIPPED",
84
+ "@@PHASE_RETRY",
83
85
  "@@SAGA_ERROR",
86
+ "@@SAGA_CANCELLED",
84
87
  "@@CMD_ERROR",
85
88
  }
86
89
  )
@@ -202,9 +205,16 @@ class Select:
202
205
 
203
206
  @dataclass(frozen=True, slots=True)
204
207
  class Fork:
205
- """Run another saga concurrently."""
208
+ """Run another saga concurrently.
209
+
210
+ By default forks are *detached*: the child gets its own cancellation
211
+ scope and is not cancelled when the parent is. Set ``attached=True``
212
+ to inherit the parent's cancellation scope — cancelling the parent
213
+ will transitively cancel the child.
214
+ """
206
215
 
207
216
  saga: Callable | Generator
217
+ attached: bool = False
208
218
 
209
219
 
210
220
  @dataclass(frozen=True, slots=True)
@@ -232,6 +242,154 @@ class Retry:
232
242
  max_delay: float = 30.0
233
243
 
234
244
 
245
+ @dataclass(frozen=True, slots=True)
246
+ class Timeout:
247
+ """Wrap a blocking effect with a deadline.
248
+
249
+ Usage in a saga::
250
+
251
+ result = yield Timeout(Call(fetch_data, args=(url,)), seconds=5)
252
+
253
+ Raises ``TimeoutError`` if the effect doesn't complete in time.
254
+ Only wraps blocking effects (``Call`` and ``Retry``).
255
+ """
256
+
257
+ effect: Call | Retry
258
+ seconds: float
259
+
260
+
261
+ @dataclass(frozen=True, slots=True)
262
+ class TryCall:
263
+ """Call a function, returning (result, None) on success or (None, error) on failure.
264
+
265
+ Unlike ``Call``, exceptions do not crash the saga::
266
+
267
+ result, error = yield TryCall(fn=might_fail)
268
+ if error:
269
+ yield Put(Action("FETCH_FAILED", payload=str(error)))
270
+ else:
271
+ yield Put(Action("FETCH_OK", payload=result))
272
+ """
273
+
274
+ fn: Callable
275
+ args: tuple = ()
276
+ kwargs: dict = field(default_factory=dict)
277
+
278
+
279
+ @dataclass(frozen=True, slots=True)
280
+ class Race:
281
+ """Run multiple sagas concurrently, return the first result.
282
+
283
+ Losers are cancelled via their cancel events as soon as a winner
284
+ completes. If all racers fail, the first error is thrown into
285
+ the parent saga::
286
+
287
+ winner = yield Race(sagas=(fetch_primary(), fetch_fallback()))
288
+
289
+ Raises ``StateError`` if *sagas* is empty.
290
+ """
291
+
292
+ sagas: tuple
293
+
294
+
295
+ @dataclass(frozen=True, slots=True)
296
+ class All:
297
+ """Run multiple sagas concurrently, wait for all to complete.
298
+
299
+ Returns a tuple of results in the same order as the input sagas.
300
+ Fail-fast: if any saga raises, remaining sagas are cancelled and
301
+ the error is thrown into the parent::
302
+
303
+ a, b = yield All(sagas=(fetch_users(), fetch_roles()))
304
+
305
+ An empty tuple returns ``()`` immediately.
306
+ """
307
+
308
+ sagas: tuple
309
+
310
+
311
+ @dataclass(frozen=True, slots=True)
312
+ class Take:
313
+ """Pause the saga until a matching action is dispatched.
314
+
315
+ Waits for *future* actions only — actions dispatched before the
316
+ Take is yielded are not matched. Returns the full ``Action``
317
+ object so the saga can inspect both type and payload::
318
+
319
+ action = yield Take("USER_CONFIRMED")
320
+ name = action.payload["name"]
321
+
322
+ An optional *timeout* (in seconds) raises ``TimeoutError`` if the
323
+ action is not dispatched in time.
324
+ """
325
+
326
+ action_type: str
327
+ timeout: float | None = None
328
+
329
+
330
+ @dataclass(frozen=True, slots=True)
331
+ class Debounce:
332
+ """Delay-then-fork: start a timer, fork *saga* when it expires.
333
+
334
+ If the parent saga yields another ``Debounce`` before the timer
335
+ fires, the previous timer is cancelled and restarted. The parent
336
+ continues immediately (non-blocking)::
337
+
338
+ # In a keystroke handler saga:
339
+ while True:
340
+ key = yield Take("@@KEY")
341
+ yield Debounce(seconds=0.3, saga=search_saga)
342
+
343
+ The debounced saga runs independently; use ``Take`` if the parent
344
+ needs the result.
345
+ """
346
+
347
+ seconds: float
348
+ saga: Callable
349
+
350
+
351
+ @dataclass(frozen=True, slots=True)
352
+ class TakeEvery:
353
+ """Fork a new saga for every matching action (auto-restart pattern).
354
+
355
+ Blocks the parent saga until cancelled. For each dispatched action
356
+ whose type matches *action_type*, a new saga is forked with the
357
+ action as argument::
358
+
359
+ # Fork a handler for every CLICK action:
360
+ yield TakeEvery("CLICK", handle_click)
361
+
362
+ def handle_click(action):
363
+ url = action.payload["url"]
364
+ result = yield Call(fetch, args=(url,))
365
+ yield Put(Action("FETCHED", payload=result))
366
+
367
+ The watcher loop runs until the parent saga is cancelled.
368
+ """
369
+
370
+ action_type: str
371
+ saga: Callable # (action: Action) -> Generator
372
+
373
+
374
+ @dataclass(frozen=True, slots=True)
375
+ class TakeLatest:
376
+ """Fork a saga for the latest matching action, cancelling the previous.
377
+
378
+ Like :class:`TakeEvery` but only the most recent saga runs — when a
379
+ new matching action arrives, the previous fork is cancelled before
380
+ the new one starts::
381
+
382
+ # Only the latest search runs:
383
+ yield TakeLatest("SEARCH", run_search)
384
+
385
+ Useful for typeahead/autocomplete patterns where earlier results
386
+ are obsolete.
387
+ """
388
+
389
+ action_type: str
390
+ saga: Callable # (action: Action) -> Generator
391
+
392
+
235
393
  # ---------------------------------------------------------------------------
236
394
  # Commands (lightweight alternative to sagas)
237
395
  # ---------------------------------------------------------------------------