@asm-agent/coding-agent 0.8.7 → 0.9.0

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 (195) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -7
  3. package/dist/asm-agent-runtime/src/__pycache__/asm_cm_driver.cpython-313.pyc +0 -0
  4. package/dist/asm-agent-runtime/src/__pycache__/phase76_address_head.cpython-313.pyc +0 -0
  5. package/dist/asm-agent-runtime/src/rlm/__init__.py +1 -1
  6. package/dist/asm-agent-runtime/src/rlm/__pycache__/__init__.cpython-312.pyc +0 -0
  7. package/dist/asm-agent-runtime/src/rlm/__pycache__/__init__.cpython-313.pyc +0 -0
  8. package/dist/asm-agent-runtime/src/rlm/__pycache__/_winjob.cpython-313.pyc +0 -0
  9. package/dist/asm-agent-runtime/src/rlm/__pycache__/bash.cpython-312.pyc +0 -0
  10. package/dist/asm-agent-runtime/src/rlm/__pycache__/bash.cpython-313.pyc +0 -0
  11. package/dist/asm-agent-runtime/src/rlm/__pycache__/harness.cpython-313.pyc +0 -0
  12. package/dist/asm-agent-runtime/src/rlm/__pycache__/mcp.cpython-312.pyc +0 -0
  13. package/dist/asm-agent-runtime/src/rlm/__pycache__/mcp.cpython-313.pyc +0 -0
  14. package/dist/asm-agent-runtime/src/rlm/__pycache__/mcp_base.cpython-312.pyc +0 -0
  15. package/dist/asm-agent-runtime/src/rlm/__pycache__/mcp_base.cpython-313.pyc +0 -0
  16. package/dist/asm-agent-runtime/src/rlm/__pycache__/repl.cpython-312.pyc +0 -0
  17. package/dist/asm-agent-runtime/src/rlm/__pycache__/repl.cpython-313.pyc +0 -0
  18. package/dist/asm-agent-runtime/src/rlm/__pycache__/skill.cpython-313.pyc +0 -0
  19. package/dist/asm-agent-runtime/src/rlm/bash.py +94 -31
  20. package/dist/asm-agent-runtime/src/rlm/repl.md +3 -3
  21. package/dist/asm-agent-runtime/src/rlm/repl.py +46 -45
  22. package/dist/asm-agent-runtime/test/__pycache__/test_agent_message_skill.cpython-313.pyc +0 -0
  23. package/dist/asm-agent-runtime/test/__pycache__/test_bash.cpython-313.pyc +0 -0
  24. package/dist/asm-agent-runtime/test/__pycache__/test_harness.cpython-313.pyc +0 -0
  25. package/dist/asm-agent-runtime/test/__pycache__/test_mcp.cpython-313.pyc +0 -0
  26. package/dist/asm-agent-runtime/test/__pycache__/test_mcp_base.cpython-313.pyc +0 -0
  27. package/dist/asm-agent-runtime/test/__pycache__/test_repl.cpython-313.pyc +0 -0
  28. package/dist/asm-agent-runtime/test/__pycache__/test_subagent_registry.cpython-313.pyc +0 -0
  29. package/dist/asm-agent-runtime/test/__pycache__/test_winjob.cpython-313.pyc +0 -0
  30. package/dist/asm-agent-runtime/test/test_bash.py +74 -19
  31. package/dist/asm-agent-runtime/test/test_repl.py +67 -3
  32. package/dist/asm-agent-runtime/uv.lock +446 -155
  33. package/dist/bundle/{anthropic-XSMQHGYB.js → anthropic-ZOZNPFXS.js} +2 -2
  34. package/dist/bundle/{azure-openai-responses-XLQAIKKH.js → azure-openai-responses-TDHCU647.js} +2 -2
  35. package/dist/bundle/{bundled-modules-5OCN73BM.js → bundled-modules-Q2YODC4B.js} +3 -3
  36. package/dist/bundle/{chunk-IAQLPSMN.js → chunk-AMKLNEAK.js} +47 -47
  37. package/dist/bundle/{chunk-2LPSALBF.js → chunk-FPY7FFJS.js} +978 -1821
  38. package/dist/bundle/{chunk-E3YE2Q7A.js → chunk-G6SC4YYW.js} +4973 -1926
  39. package/dist/bundle/{chunk-YPNK4MRU.js → chunk-QLHAP3FT.js} +1 -1
  40. package/dist/bundle/{cli-main-QYX5R3LF.js → cli-main-277AG2BR.js} +3 -3
  41. package/dist/bundle/cli.js +1 -1
  42. package/dist/bundle/{google-ZXJEPWJA.js → google-Q3AJWUOY.js} +1 -1
  43. package/dist/bundle/{google-vertex-WAF3RIJ6.js → google-vertex-2CSNOUBS.js} +1 -1
  44. package/dist/bundle/{main-EOUAQCJH.js → main-TNXWJXIU.js} +3 -3
  45. package/dist/bundle/{mistral-GDZXSXRS.js → mistral-XRYJGLLG.js} +1 -1
  46. package/dist/bundle/{openai-codex-responses-7XMPW4LG.js → openai-codex-responses-C6BRO2BN.js} +2 -2
  47. package/dist/bundle/{openai-completions-LNSVGDMF.js → openai-completions-DNXCK2JW.js} +4 -4
  48. package/dist/bundle/{openai-responses-MFZOBMIT.js → openai-responses-BEMWVIXE.js} +2 -2
  49. package/dist/config.d.ts +0 -1
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +0 -3
  52. package/dist/config.js.map +1 -1
  53. package/dist/core/agent-session-config.d.ts +1 -2
  54. package/dist/core/agent-session-config.d.ts.map +1 -1
  55. package/dist/core/agent-session-config.js +0 -3
  56. package/dist/core/agent-session-config.js.map +1 -1
  57. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  58. package/dist/core/agent-session-runtime.js +0 -8
  59. package/dist/core/agent-session-runtime.js.map +1 -1
  60. package/dist/core/agent-session-services.d.ts +0 -2
  61. package/dist/core/agent-session-services.d.ts.map +1 -1
  62. package/dist/core/agent-session-services.js +0 -22
  63. package/dist/core/agent-session-services.js.map +1 -1
  64. package/dist/core/agent-session.d.ts +8 -1
  65. package/dist/core/agent-session.d.ts.map +1 -1
  66. package/dist/core/agent-session.js +100 -22
  67. package/dist/core/agent-session.js.map +1 -1
  68. package/dist/core/kernel/bootstrap.d.ts.map +1 -1
  69. package/dist/core/kernel/bootstrap.js +1 -1
  70. package/dist/core/kernel/bootstrap.js.map +1 -1
  71. package/dist/core/kernel/repl-manager.d.ts +32 -8
  72. package/dist/core/kernel/repl-manager.d.ts.map +1 -1
  73. package/dist/core/kernel/repl-manager.js +401 -102
  74. package/dist/core/kernel/repl-manager.js.map +1 -1
  75. package/dist/core/kernel/shared.d.ts +11 -7
  76. package/dist/core/kernel/shared.d.ts.map +1 -1
  77. package/dist/core/kernel/shared.js +2 -5
  78. package/dist/core/kernel/shared.js.map +1 -1
  79. package/dist/core/mcp/mcp-manager.d.ts +4 -2
  80. package/dist/core/mcp/mcp-manager.d.ts.map +1 -1
  81. package/dist/core/mcp/mcp-manager.js +9 -7
  82. package/dist/core/mcp/mcp-manager.js.map +1 -1
  83. package/dist/core/model-registry.d.ts.map +1 -1
  84. package/dist/core/model-registry.js +1 -1
  85. package/dist/core/model-registry.js.map +1 -1
  86. package/dist/core/ollama-models.d.ts +3 -1
  87. package/dist/core/ollama-models.d.ts.map +1 -1
  88. package/dist/core/ollama-models.js +31 -10
  89. package/dist/core/ollama-models.js.map +1 -1
  90. package/dist/core/prime-inference-auth.d.ts +0 -9
  91. package/dist/core/prime-inference-auth.d.ts.map +1 -1
  92. package/dist/core/prime-inference-auth.js +0 -50
  93. package/dist/core/prime-inference-auth.js.map +1 -1
  94. package/dist/core/provider-display-names.d.ts.map +1 -1
  95. package/dist/core/provider-display-names.js +0 -1
  96. package/dist/core/provider-display-names.js.map +1 -1
  97. package/dist/core/session-manager.d.ts +0 -4
  98. package/dist/core/session-manager.d.ts.map +1 -1
  99. package/dist/core/session-manager.js +0 -22
  100. package/dist/core/session-manager.js.map +1 -1
  101. package/dist/core/settings-manager.d.ts +4 -16
  102. package/dist/core/settings-manager.d.ts.map +1 -1
  103. package/dist/core/settings-manager.js +13 -43
  104. package/dist/core/settings-manager.js.map +1 -1
  105. package/dist/core/slash-commands.d.ts.map +1 -1
  106. package/dist/core/slash-commands.js +0 -5
  107. package/dist/core/slash-commands.js.map +1 -1
  108. package/dist/core/tools/acp-mcp.d.ts +6 -0
  109. package/dist/core/tools/acp-mcp.d.ts.map +1 -0
  110. package/dist/core/tools/acp-mcp.js +86 -0
  111. package/dist/core/tools/acp-mcp.js.map +1 -0
  112. package/dist/core/tools/index.d.ts +1 -0
  113. package/dist/core/tools/index.d.ts.map +1 -1
  114. package/dist/core/tools/index.js +1 -0
  115. package/dist/core/tools/index.js.map +1 -1
  116. package/dist/core/tools/ipython.d.ts.map +1 -1
  117. package/dist/core/tools/ipython.js +9 -4
  118. package/dist/core/tools/ipython.js.map +1 -1
  119. package/dist/main.d.ts.map +1 -1
  120. package/dist/main.js +2 -11
  121. package/dist/main.js.map +1 -1
  122. package/dist/modes/acp/acp-mode.d.ts.map +1 -1
  123. package/dist/modes/acp/acp-mode.js +1 -1
  124. package/dist/modes/acp/acp-mode.js.map +1 -1
  125. package/dist/modes/agent-connection/daemon-agent-connection.d.ts +0 -2
  126. package/dist/modes/agent-connection/daemon-agent-connection.d.ts.map +1 -1
  127. package/dist/modes/agent-connection/daemon-agent-connection.js +0 -2
  128. package/dist/modes/agent-connection/daemon-agent-connection.js.map +1 -1
  129. package/dist/modes/agents-view/agents-view-mode.d.ts.map +1 -1
  130. package/dist/modes/agents-view/agents-view-mode.js +0 -19
  131. package/dist/modes/agents-view/agents-view-mode.js.map +1 -1
  132. package/dist/modes/daemon/daemon-mode.d.ts +1 -10
  133. package/dist/modes/daemon/daemon-mode.d.ts.map +1 -1
  134. package/dist/modes/daemon/daemon-mode.js +134 -274
  135. package/dist/modes/daemon/daemon-mode.js.map +1 -1
  136. package/dist/modes/daemon/daemon-protocol.d.ts +11 -5
  137. package/dist/modes/daemon/daemon-protocol.d.ts.map +1 -1
  138. package/dist/modes/daemon/daemon-protocol.js +6 -8
  139. package/dist/modes/daemon/daemon-protocol.js.map +1 -1
  140. package/dist/modes/daemon/daemon-supervisor.d.ts +0 -3
  141. package/dist/modes/daemon/daemon-supervisor.d.ts.map +1 -1
  142. package/dist/modes/daemon/daemon-supervisor.js +18 -52
  143. package/dist/modes/daemon/daemon-supervisor.js.map +1 -1
  144. package/dist/modes/daemon/daemon-worker-protocol.d.ts +1 -6
  145. package/dist/modes/daemon/daemon-worker-protocol.d.ts.map +1 -1
  146. package/dist/modes/daemon/daemon-worker-protocol.js +0 -2
  147. package/dist/modes/daemon/daemon-worker-protocol.js.map +1 -1
  148. package/dist/modes/interactive/auth-flows.d.ts +0 -2
  149. package/dist/modes/interactive/auth-flows.d.ts.map +1 -1
  150. package/dist/modes/interactive/auth-flows.js +1 -101
  151. package/dist/modes/interactive/auth-flows.js.map +1 -1
  152. package/dist/modes/interactive/components/assistant-message.d.ts +6 -1
  153. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  154. package/dist/modes/interactive/components/assistant-message.js +13 -3
  155. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  156. package/dist/modes/interactive/components/footer.d.ts +1 -1
  157. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  158. package/dist/modes/interactive/components/footer.js +2 -2
  159. package/dist/modes/interactive/components/footer.js.map +1 -1
  160. package/dist/modes/interactive/components/mermaid.d.ts +12 -0
  161. package/dist/modes/interactive/components/mermaid.d.ts.map +1 -0
  162. package/dist/modes/interactive/components/mermaid.js +63 -0
  163. package/dist/modes/interactive/components/mermaid.js.map +1 -0
  164. package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
  165. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  166. package/dist/modes/interactive/components/settings-selector.js +10 -0
  167. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  168. package/dist/modes/interactive/feature-hints.d.ts.map +1 -1
  169. package/dist/modes/interactive/feature-hints.js +0 -4
  170. package/dist/modes/interactive/feature-hints.js.map +1 -1
  171. package/dist/modes/interactive/interactive-mode.d.ts +1 -7
  172. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  173. package/dist/modes/interactive/interactive-mode.js +23 -279
  174. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  175. package/dist/utils/version-check.d.ts.map +1 -1
  176. package/dist/utils/version-check.js +1 -1
  177. package/dist/utils/version-check.js.map +1 -1
  178. package/docs/settings.md +1 -31
  179. package/docs/usage.md +1 -4
  180. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  181. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  182. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  183. package/examples/extensions/sandbox/package-lock.json +2 -2
  184. package/examples/extensions/sandbox/package.json +1 -1
  185. package/examples/extensions/with-deps/package-lock.json +2 -2
  186. package/examples/extensions/with-deps/package.json +1 -1
  187. package/package.json +6 -5
  188. package/dist/core/agent-traces.d.ts +0 -130
  189. package/dist/core/agent-traces.d.ts.map +0 -1
  190. package/dist/core/agent-traces.js +0 -746
  191. package/dist/core/agent-traces.js.map +0 -1
  192. package/dist/core/telemetry.d.ts +0 -92
  193. package/dist/core/telemetry.d.ts.map +0 -1
  194. package/dist/core/telemetry.js +0 -633
  195. package/dist/core/telemetry.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0] - 2026-09-03
4
+
5
+ - Added native callable tools for MCP servers supplied by ACP clients. ([#2002](https://github.com/PrimeIntellect-ai/prime-agent/pull/2002))
6
+ - Fixed `bash()` to capture all foreground command output before finalizing results by using an ordered per-command completion marker; output written after the marker (e.g. by `EXIT` traps or background jobs) is not in the awaited result but stays visible via `handle.output()`/`tail()`.
7
+ Agent messages now use core session admission to choose immediate or queued delivery.
8
+ - Fixed concurrent `execute_bash_and_wait` commands sharing one bash abort controller: each `executeBash` invocation now gets its own controller, so a finishing command no longer clears a still-running command's abort state and `abortBash` cancels every in-flight command.
9
+ - Namespaced kernel host handler results so handler fields cannot overwrite host reply protocol metadata.
10
+ - Render Mermaid code blocks in assistant messages as inline Unicode diagrams, with a "Mermaid diagrams" setting (off/final/streaming, default streaming).
11
+ - Fixed graceful Python kernel disposal so timed-out final snapshots are cancelled before teardown.
12
+ - Reduced kernel memory spikes during namespace snapshots: the payload now pickles straight into the staged file instead of building serialized copies in memory (peak snapshot overhead ~3.9x payload -> ~1x; ENG-5819).
13
+ - Made cross-worker agent lists current without broadcasting duplicate peer rosters.
14
+ - Fixed invalid kernel protocol frames hanging requests by rejecting the affected request and replacing the kernel from its latest state snapshot.
15
+ - Fixed remote agent messages being delivered twice when the daemon request timed out or the response was lost: the message is now sent exactly once per call, and post-send failures surface as errors instead of triggering a resend.
16
+ - Fixed kernel teardown so session cleanup and signal handling share one bounded graceful shutdown path.
17
+
18
+ ## [0.8.8] - 2026-09-03
19
+
20
+ - Fixed selected Ollama models failing runtime authentication before requests were sent.
21
+
3
22
  ## [0.8.7] - 2026-09-03
4
23
 
5
24
  - Added automatic Ollama model discovery and selection from the `/model` menu.
package/README.md CHANGED
@@ -154,7 +154,6 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
154
154
  | `/new`, `/clear` | Start a new session |
155
155
  | `/name <name>` | Set session display name |
156
156
  | `/session` | Show session info (file, ID, messages) |
157
- | `/traces [status\|on\|off\|preview\|upload-current\|upload-all\|login]` | Preview traces, run one-shot current/all uploads, and manage automatic sharing (`upload` aliases `upload-current`) |
158
157
  | `/usage` | Show token, cost, and context usage |
159
158
  | `/tree` | Jump to any point in the session and continue from there |
160
159
  | `/fork` | Create a new session from a previous user message |
@@ -258,7 +257,7 @@ See [docs/settings.md](docs/settings.md) for all options.
258
257
 
259
258
  ### Update checks
260
259
 
261
- ASM Agent stable builds fetch `https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev/latest.json` to check whether a newer version exists. Beta builds fetch `beta.json` and remain on the beta channel. Override the base URL with `ASM_AGENT_DOWNLOAD_BASE_URL`. Disable version checks with `PI_SKIP_VERSION_CHECK=1`.
260
+ ASM Agent stable builds fetch `https://pub-ca377e94504d4b9cb997babbbc998159.r2.dev/latest.json` to check whether a newer version exists. Beta builds fetch `beta.json` and remain on the beta channel. Override the base URL with `ASM_AGENT_DOWNLOAD_BASE_URL`. Disable version checks with `PI_SKIP_VERSION_CHECK=1`.
262
261
 
263
262
  Use `--offline` or `PI_OFFLINE=1` to disable startup network operations, including update checks and package update checks.
264
263
 
@@ -676,14 +675,10 @@ asm-agent --thinking high "Solve this complex problem"
676
675
  | `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) |
677
676
  | `PI_OFFLINE` | Disable startup network operations, including update checks and package update checks |
678
677
  | `PI_SKIP_VERSION_CHECK` | Skip the ASM Agent version update check at startup. This prevents the release manifest request |
679
- | `ASM_AGENT_TELEMETRY` | Override pseudonymous aggregate usage analytics with `1`/`true`/`yes` or `0`/`false`/`no` |
680
- | `ASM_AGENT_TELEMETRY_ENDPOINT` | Override the aggregate analytics ingestion endpoint |
681
678
  | `DO_NOT_TRACK` | Disable aggregate usage analytics when set to `1`/`true`/`yes` |
682
679
  | `ASM_AGENT_DOWNLOAD_BASE_URL` | Override the ASM Agent release manifest and tarball base URL |
683
680
  | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) |
684
- | `PRIME_API_KEY` | Prime Inference API key; also used for trace sharing if it has `agent_traces` scope |
685
- | `ASM_AGENT_TRACES_API_KEY` | Prime API key used only for opt-in trace sharing |
686
- | `ASM_AGENT_TRACES_BASE_URL` | Override the ASM Agent trace upload API base URL |
681
+ | `PRIME_API_KEY` | Prime Inference API key |
687
682
  | `ASM_AGENT_KERNEL_PYTHON` | Use an existing Python environment with `asm-agent-runtime` instead of auto-bootstrapping `~/.asm-agent/kernel-venv` |
688
683
  | `VISUAL`, `EDITOR` | External editor for Ctrl+G |
689
684
 
@@ -57,7 +57,7 @@ def _spawn_handle_from_payload(payload: Any) -> RLMSpawnHandle:
57
57
  def _parse_host_reply(request_type: str, reply: dict[str, Any]) -> dict[str, Any]:
58
58
  status = reply.get("status")
59
59
  if status == "ok":
60
- return {k: v for k, v in reply.items() if k != "status"}
60
+ return reply["result"]
61
61
  if status == "error":
62
62
  raise RuntimeError(str(reply.get("error") or f"host request {request_type} failed"))
63
63
  raise RuntimeError(f"host request {request_type} returned unexpected status: {status!r}")
@@ -6,6 +6,7 @@ import asyncio
6
6
  import atexit
7
7
  import json
8
8
  import os
9
+ import secrets
9
10
  import selectors
10
11
  import shutil
11
12
  import signal
@@ -36,6 +37,9 @@ _READ_CHUNK = 65536
36
37
  # Fixed child-side fd for the status channel; POSIX shells (notably dash) only
37
38
  # guarantee single-digit fds in redirection syntax.
38
39
  _STATUS_FD = 9
40
+ _OUTPUT_FD = 8
41
+ _COMPLETION_PREFIX = b"\x1easm-agent-complete:"
42
+ _COMPLETION_SUFFIX = b"\x1f"
39
43
  # Cancelled one-shot awaits: TERM grace before the group KILL, then the bounded
40
44
  # wait for a confirmed group exit before CancelledError propagates.
41
45
  _CANCEL_TERM_GRACE = 0.5
@@ -117,6 +121,10 @@ class BashHandle:
117
121
  self._buffer = _BoundedBuffer()
118
122
  self._done = threading.Event()
119
123
  self._eof = threading.Event()
124
+ self._completion_terminal = threading.Event()
125
+ self._completion_output: str | None = None
126
+ self._completion_lock = threading.Lock()
127
+ self._completion_pending = b""
120
128
  self._status: int | None = None
121
129
  self._status_known = threading.Event()
122
130
  self._reaped = False
@@ -134,6 +142,7 @@ class BashHandle:
134
142
  # True only while the pump moves a chunk from the pipe into the buffer.
135
143
  self._pump_transfer = False
136
144
  self._job: int | None = None
145
+ self._completion_marker: bytes | None = None
137
146
  status_write = -1
138
147
  if _IS_POSIX:
139
148
  # Full-duplex status channel: the child end rides in as stdin (fd 0)
@@ -151,9 +160,19 @@ class BashHandle:
151
160
  os.close(self._status_read)
152
161
  os.close(status_write)
153
162
  raise
154
- script = _status_script(_with_prefix(command))
163
+ completion_token = secrets.token_hex(32)
164
+ # Halves stop passive echoes; a deliberate forgery freezes only this call while later bytes stay live.
165
+ token_midpoint = len(completion_token) // 2
166
+ self._completion_marker = (
167
+ _COMPLETION_PREFIX + completion_token.encode("ascii") + _COMPLETION_SUFFIX
168
+ )
169
+ script = _status_script(
170
+ _with_prefix(command),
171
+ completion_token[:token_midpoint],
172
+ completion_token[token_midpoint:],
173
+ )
155
174
  else:
156
- # Windows: the child is created suspended, atomically inside the kill-on-close job.
175
+ # Windows lacks a foreground-status channel, so its exit drain stays best-effort.
157
176
  script = _with_prefix(command)
158
177
  self._job = _winjob.create_job()
159
178
  if self._job is None:
@@ -304,8 +323,6 @@ class BashHandle:
304
323
  stdout = self._proc.stdout
305
324
  assert stdout is not None
306
325
  if not _IS_POSIX:
307
- # Windows: no FIONREAD fence; the drain keeps its quiescence
308
- # heuristic (best-effort parity).
309
326
  try:
310
327
  while chunk := stdout.read1(_READ_CHUNK):
311
328
  self._buffer.write(chunk)
@@ -314,9 +331,6 @@ class BashHandle:
314
331
  stdout.close()
315
332
  self._eof.set()
316
333
  return
317
- # POSIX: select-gate the read and flag the read->commit window, so the
318
- # drain fence never sees "pipe empty + buffer quiescent" while a chunk
319
- # is in flight between the pipe read and the buffer commit.
320
334
  fd = stdout.fileno()
321
335
  try:
322
336
  with selectors.DefaultSelector() as sel:
@@ -328,17 +342,54 @@ class BashHandle:
328
342
  chunk = os.read(fd, _READ_CHUNK)
329
343
  if not chunk:
330
344
  break
331
- self._buffer.write(chunk)
345
+ self._consume_output(chunk)
332
346
  finally:
333
347
  self._pump_transfer = False
334
348
  except (OSError, ValueError):
335
349
  pass
350
+ self._abandon_completion()
336
351
  try:
337
352
  stdout.close()
338
353
  except OSError:
339
354
  pass
340
355
  self._eof.set()
341
356
 
357
+ def _consume_output(self, chunk: bytes) -> None:
358
+ marker = self._completion_marker
359
+ assert marker is not None
360
+ with self._completion_lock:
361
+ if self._completion_terminal.is_set():
362
+ self._buffer.write(chunk)
363
+ return
364
+ data = self._completion_pending + chunk
365
+ marker_at = data.find(marker)
366
+ if marker_at >= 0:
367
+ self._buffer.write(data[:marker_at])
368
+ self._completion_pending = b""
369
+ self._completion_output = self._buffer.text()
370
+ self._completion_terminal.set()
371
+ self._buffer.write(data[marker_at + len(marker) :])
372
+ return
373
+ retained = 0
374
+ for size in range(min(len(data), len(marker) - 1), 0, -1):
375
+ if data.endswith(marker[:size]):
376
+ retained = size
377
+ break
378
+ self._buffer.write(data[:-retained] if retained else data)
379
+ self._completion_pending = data[-retained:] if retained else b""
380
+
381
+ def _abandon_completion(self) -> None:
382
+ with self._completion_lock:
383
+ if self._completion_terminal.is_set():
384
+ return
385
+ self._buffer.write(self._completion_pending)
386
+ self._completion_pending = b""
387
+ self._completion_terminal.set()
388
+
389
+ def _wait_for_completion(self) -> str | None:
390
+ self._completion_terminal.wait()
391
+ return self._completion_output
392
+
342
393
  def _report(self) -> None:
343
394
  # Finalize at foreground completion (status channel), not EOF, so
344
395
  # `cmd &` does not hang the await; the shell then `wait`s for its
@@ -355,8 +406,10 @@ class BashHandle:
355
406
  # (parsed status, EOF, garbage, exception) must set it.
356
407
  self._status_known.set()
357
408
  if status is not None:
358
- self._drain_grace()
359
- self._finalize(status)
409
+ output = self._wait_for_completion()
410
+ if output is None:
411
+ self._drain_grace()
412
+ self._finalize(status, output)
360
413
 
361
414
  def _watch(self) -> None:
362
415
  # Observe shell death independently of the status socket: an early
@@ -377,6 +430,7 @@ class BashHandle:
377
430
  with self._callback_lock:
378
431
  delivered = self._status
379
432
  if delivered is None and not self._done.is_set():
433
+ self._abandon_completion()
380
434
  self._drain_grace()
381
435
  self._finalize(exit_code)
382
436
  with self._kill_lock:
@@ -439,10 +493,7 @@ class BashHandle:
439
493
  os.close(self._wake_read)
440
494
 
441
495
  def _drain_grace(self) -> None:
442
- # Bounded wait so the result includes foreground output still in the pipe:
443
- # EOF arrives immediately without background jobs, otherwise stop once the
444
- # buffer is quiescent for one tick AND the pipe holds no unread bytes (a
445
- # slow pump must not lose output the shell wrote before its status).
496
+ # Best-effort fallback when process exit/EOF arrives without a sentinel.
446
497
  deadline = time.monotonic() + 0.5
447
498
  size = self._buffer.size()
448
499
  while time.monotonic() < deadline:
@@ -472,13 +523,13 @@ class BashHandle:
472
523
  return False
473
524
  return pending > 0
474
525
 
475
- def _finalize(self, exit_code: int) -> None:
526
+ def _finalize(self, exit_code: int, output: str | None = None) -> None:
476
527
  with self._callback_lock:
477
528
  if self._done.is_set():
478
529
  return
479
530
  self._result = BashResult(
480
531
  exit_code=exit_code,
481
- output=self._buffer.text(),
532
+ output=self._buffer.text() if output is None else output,
482
533
  duration=time.monotonic() - self._started,
483
534
  )
484
535
  self._done.set()
@@ -641,6 +692,9 @@ def bash(command: str) -> BashHandle:
641
692
  POSIX; a kill-on-close job object on Windows entered while the child is
642
693
  still suspended, so no descendant can escape it and kill()/crash cleanup
643
694
  are unconditional -- bash() raises if containment cannot be established.
695
+ Output written after the completion fence (e.g. by an EXIT trap or a
696
+ background job) is not in BashResult.output but stays visible via
697
+ handle.output()/tail().
644
698
  """
645
699
  if not isinstance(command, str) or not command:
646
700
  raise TypeError("command must be a non-empty str")
@@ -674,26 +728,35 @@ def _with_prefix(command: str) -> str:
674
728
  return f"{prefix}\n{command}" if prefix else command
675
729
 
676
730
 
677
- def _status_script(command: str) -> str:
678
- # The status socket arrives as stdin (fd 0); the prologue dups it to the
679
- # single-digit _STATUS_FD (dash rejects multi-digit fds in redirections at
680
- # parse time) and points stdin at /dev/null, so no other copy remains. The
681
- # gate read blocks until the parent has journaled the pid; EOF (parent died
682
- # first) exits without running the command. The brace group runs the command
683
- # with the status fd closed so `&` children do not inherit it; the trailing
684
- # `wait` keeps the shell alive as group leader until its own jobs exit
685
- # (double-forked daemons stay out of scope).
731
+ def _fence_printf() -> str:
732
+ # `\command -p printf` defeats alias expansion but not a user-defined shell
733
+ # function named `command`, which would swallow both fence frames and leave
734
+ # the await hanging until the shell dies (wedged behind background jobs). A
735
+ # slash-qualified command name bypasses function and alias lookup for
736
+ # ordinary command names, so resolve printf on the system default utility PATH.
737
+ path = shutil.which("printf", path=os.confstr("CS_PATH") or os.defpath)
738
+ if path and "'" not in path:
739
+ return f"'{path}'"
740
+ return "\\command -p printf"
741
+
742
+
743
+ def _status_script(command: str, completion_a: str, completion_b: str) -> str:
744
+ # Closed control fds preserve background behavior; supported shells atomically write the frame.
745
+ emit = _fence_printf()
686
746
  return (
687
- f"exec {_STATUS_FD}>&0 0</dev/null\n"
747
+ f"exec {_STATUS_FD}>&0 {_OUTPUT_FD}>&1 0</dev/null\n"
688
748
  f"read -r _asm_agent_gate <&{_STATUS_FD} || exit 127\n"
689
749
  "{\n"
690
750
  f"{command}\n"
691
- f"}} {_STATUS_FD}>&-\n"
692
- "__prime_status=$?\n"
693
- f"printf '%s\\n' \"$__prime_status\" >&{_STATUS_FD}\n"
694
- f"exec {_STATUS_FD}>&-\n"
751
+ f"}} {_OUTPUT_FD}>&- {_STATUS_FD}>&-\n"
752
+ "__asm_status=$?\n"
753
+ "\\set +x\n"
754
+ f"{emit} '\\036asm-agent-complete:%s%s\\037' "
755
+ f"'{completion_a}' '{completion_b}' >&{_OUTPUT_FD} || exit \"$__asm_status\"\n"
756
+ f"{emit} '%s\\n' \"$__asm_status\" >&{_STATUS_FD}\n"
757
+ f"exec {_OUTPUT_FD}>&- {_STATUS_FD}>&-\n"
695
758
  "wait\n"
696
- 'exit "$__prime_status"\n'
759
+ 'exit "$__asm_status"\n'
697
760
  )
698
761
 
699
762
 
@@ -3,7 +3,7 @@
3
3
  `python -m rlm.repl` starts a CPython REPL runtime that executes code cells in
4
4
  one persistent `__main__` namespace on a single asyncio event loop. The wire
5
5
  format is newline-delimited JSON: one object per line, UTF-8, no other framing.
6
- The current protocol version is `2`; the runtime announces it in the `ready`
6
+ The current protocol version is `3`; the runtime announces it in the `ready`
7
7
  event.
8
8
 
9
9
  ## Channels
@@ -29,7 +29,7 @@ event.
29
29
  |---|---|
30
30
  | `execute` | `{"type":"execute","id":str,"code":str}` |
31
31
  | `interrupt` | `{"type":"interrupt","id"?:str}` — no reply |
32
- | `host_reply` | `{"type":"host_reply","id":str,"data":{...}}` — no reply |
32
+ | `host_reply` | `{"type":"host_reply","id":str,"data":{"status":"ok","result":{...}}}` or an error envelope — no reply |
33
33
  | `snapshot` | `{"type":"snapshot","id":str,"path":str,"manifest_path":str,"max_bytes"?:int,"max_variable_bytes"?:int,"prune_oversized"?:bool}` |
34
34
  | `restore` | `{"type":"restore","id":str,"path":str}` |
35
35
  | `list_names` | `{"type":"list_names","id":str}` |
@@ -42,7 +42,7 @@ runtime keeps serving. Closing stdin is equivalent to `shutdown`.
42
42
 
43
43
  ## Events
44
44
 
45
- - `{"event":"ready","protocol":2,"python":"3.13.11"}` — sent once at startup;
45
+ - `{"event":"ready","protocol":3,"python":"3.13.11"}` — sent once at startup;
46
46
  the handshake. No banner precedes it.
47
47
  - `{"event":"stdout"|"stderr","id":str|null,"text":str}` — captured output.
48
48
  `id` is the cell whose Python execution context performed the write; asyncio
@@ -31,7 +31,7 @@ from typing import Any
31
31
 
32
32
  from .bash import _kill_live_handles
33
33
 
34
- PROTOCOL_VERSION = 2
34
+ PROTOCOL_VERSION = 3
35
35
 
36
36
  DEFAULT_SNAPSHOT_MAX_BYTES = 256 * 1024 * 1024
37
37
  DEFAULT_SNAPSHOT_MAX_VARIABLE_BYTES = 16 * 1024 * 1024
@@ -607,20 +607,19 @@ class _SnapshotSizeLimitExceeded(Exception):
607
607
  pass
608
608
 
609
609
 
610
- class _SnapshotBuffer:
611
- def __init__(self, limit: int) -> None:
612
- import io
613
-
614
- self._buf = io.BytesIO()
610
+ class _CappedWriter:
611
+ def __init__(self, sink: Any, limit: int) -> None:
612
+ self._sink = sink
615
613
  self._limit = limit
614
+ self.written = 0
616
615
 
617
- def write(self, chunk: bytes) -> int:
618
- if self._buf.tell() + len(chunk) > self._limit:
616
+ def write(self, chunk: Any) -> int:
617
+ size = len(chunk)
618
+ if self.written + size > self._limit:
619
619
  raise _SnapshotSizeLimitExceeded()
620
- return self._buf.write(chunk)
621
-
622
- def getvalue(self) -> bytes:
623
- return self._buf.getvalue()
620
+ self._sink.write(chunk)
621
+ self.written += size
622
+ return size
624
623
 
625
624
 
626
625
  def _snapshot_state(
@@ -655,9 +654,9 @@ def _snapshot_state(
655
654
  continue
656
655
  remaining = max_bytes - total
657
656
  limit = max_variable_bytes if prune_oversized else min(max_variable_bytes, remaining)
658
- buffer = _SnapshotBuffer(limit)
657
+ buffer = io.BytesIO()
659
658
  try:
660
- dill.dump(value, buffer)
659
+ dill.dump(value, _CappedWriter(buffer, limit))
661
660
  blob = buffer.getvalue()
662
661
  except _SnapshotSizeLimitExceeded:
663
662
  if not prune_oversized and remaining < max_variable_bytes:
@@ -706,39 +705,41 @@ def _snapshot_state(
706
705
  previous = None
707
706
  try:
708
707
  try:
709
- def serialize(candidate: dict[str, bytes]) -> bytes | None:
710
- buffer = _SnapshotBuffer(max_bytes)
711
- try:
712
- dill.dump(candidate, buffer)
713
- except _SnapshotSizeLimitExceeded:
714
- return None
715
- return buffer.getvalue()
716
-
717
- serialized_payload = serialize(payload)
718
- if serialized_payload is None:
719
- items = list(payload.items())
720
- serialized_payload = serialize({})
721
- if serialized_payload is None:
722
- return {"error": "write failed: snapshot exceeds aggregate snapshot size cap"}
723
- # Keep the largest insertion-order prefix whose complete pickle fits.
724
- # Prefix pickle size is monotonic because each prefix only adds a string key and bytes value.
725
- low, high = 0, len(items) - 1
726
- while low < high:
727
- mid = (low + high + 1) // 2
728
- candidate = serialize(dict(items[:mid]))
729
- if candidate is None:
730
- high = mid - 1
731
- else:
732
- low = mid
733
- serialized_payload = candidate
734
- for name, _ in items[low:]:
735
- skipped.append({"name": name, "reason": "exceeds aggregate snapshot size cap"})
736
- payload = dict(items[:low])
737
-
738
708
  fh, tmp = stage_temp(path, "wb")
739
709
  with fh:
740
- fh.write(serialized_payload)
741
- bytes_written = len(serialized_payload)
710
+ def dump_to_temp(candidate: dict[str, bytes]) -> int | None:
711
+ writer = _CappedWriter(fh, max_bytes)
712
+ try:
713
+ dill.dump(candidate, writer)
714
+ except _SnapshotSizeLimitExceeded:
715
+ return None
716
+ return writer.written
717
+
718
+ def redump_to_temp(candidate: dict[str, bytes]) -> int | None:
719
+ fh.seek(0)
720
+ fh.truncate()
721
+ return dump_to_temp(candidate)
722
+
723
+ bytes_written = dump_to_temp(payload)
724
+ if bytes_written is None:
725
+ # Prefix pickle size is monotonic because each prefix only adds a string key and bytes value.
726
+ items = list(payload.items())
727
+ if redump_to_temp({}) is None:
728
+ return {"error": "write failed: snapshot exceeds aggregate snapshot size cap"}
729
+ low, high = 0, len(items) - 1
730
+ while low < high:
731
+ mid = (low + high + 1) // 2
732
+ if redump_to_temp(dict(items[:mid])) is None:
733
+ high = mid - 1
734
+ else:
735
+ low = mid
736
+ for name, _ in items[low:]:
737
+ skipped.append({"name": name, "reason": "exceeds aggregate snapshot size cap"})
738
+ payload = dict(items[:low])
739
+ # The search's last attempt may have overflowed the temp; rewrite the chosen prefix.
740
+ bytes_written = redump_to_temp(payload)
741
+ if bytes_written is None:
742
+ return {"error": "write failed: snapshot exceeds aggregate snapshot size cap"}
742
743
  saved = sorted(payload.keys())
743
744
  pruned = sorted(name for name in oversized if name in ns) if prune_oversized else []
744
745
  manifest = {