world-model-optimizer 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,880 @@
1
+ # Copyright (c) 2026 Experiential Labs. All rights reserved.
2
+
3
+ """Detached lifecycle and workspace transport for hosted E2B agent sessions.
4
+
5
+ ``wmo run <agent-id> --detach`` starts a normal platform-owned agent session
6
+ and returns immediately, remembering it as the current session in WMO user
7
+ state. Later ``wmo run --send/--attach/--end`` invocations address that
8
+ session (or any accessible session via ``--session <id>``) through the same
9
+ authenticated platform protocol the web app uses: durable transcript polling,
10
+ queued commands, live workspace patches, and the final workspace handoff.
11
+
12
+ No local process runs between invocations, so a persisted checkpoint (the
13
+ transcript cursor plus the last synchronized workspace snapshot) lets the next
14
+ command catch up on hosted workspace patches and upload local edits before
15
+ proceeding. Sessions remain ordinary platform records: they stay visible and
16
+ controllable from the web application throughout.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import sys
23
+ import threading
24
+ import time
25
+ from dataclasses import dataclass
26
+ from datetime import UTC, datetime
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING, Literal
29
+
30
+ import typer
31
+ from rich.console import Console
32
+
33
+ from wmo.cli.session_state import (
34
+ DetachedSessionState,
35
+ SessionStateError,
36
+ SessionStateStore,
37
+ WorkspaceCheckpoint,
38
+ )
39
+ from wmo.cli.workspace_sync import (
40
+ WorkspaceSnapshot,
41
+ WorkspaceSyncError,
42
+ advance_snapshot_paths,
43
+ apply_patch_to_snapshot,
44
+ apply_workspace_patch,
45
+ snapshot_from_archive,
46
+ snapshot_workspace,
47
+ sync_workspace,
48
+ write_conflict_archive,
49
+ )
50
+ from wmo.harness.live_session import SessionEvent
51
+ from wmo.harness.workspace_patch import WorkspacePatchError, build_workspace_patch
52
+ from wmo.platform.client import PlatformClient, PlatformError
53
+
54
+ if TYPE_CHECKING:
55
+ from collections.abc import Callable, Iterable
56
+
57
+ from wmo.cli.workspace_sync import SyncResult
58
+ from wmo.platform.client import (
59
+ RemoteAgentEventPage,
60
+ RemoteAgentSession,
61
+ RemoteAgentSessionEvent,
62
+ )
63
+ from wmo.platform.credentials import PlatformCredentials
64
+
65
+ _console = Console()
66
+
67
+ TERMINAL_STATUSES = frozenset({"ended", "failed"})
68
+ SessionAction = Literal["send", "attach", "end"]
69
+
70
+ _POLL_INTERVAL_S = 0.5
71
+ _WORKSPACE_SYNC_TICK_S = 1.0
72
+ # A session whose driver died keeps answering `running` on the events poll;
73
+ # the detail read reconciles it server-side, so probe it occasionally.
74
+ _STALE_PROBE_S = 30.0
75
+
76
+
77
+ def patch_revision(event: RemoteAgentSessionEvent) -> str:
78
+ """Extract the patch revision announced by one ``workspace_patch`` event."""
79
+ revision = event.payload.get("revision")
80
+ if not isinstance(revision, str) or not revision:
81
+ raise WorkspaceSyncError("workspace patch event has no revision")
82
+ return revision
83
+
84
+
85
+ class LiveWorkspace:
86
+ """Bidirectional file transport between one local root and a hosted session."""
87
+
88
+ def __init__(
89
+ self,
90
+ client: PlatformClient,
91
+ agent_id: str,
92
+ session_id: str,
93
+ root: Path,
94
+ synchronized: WorkspaceSnapshot,
95
+ conflicts: Iterable[str] = (),
96
+ ) -> None:
97
+ """Bind the transport to its synchronized base snapshot and known conflicts."""
98
+ self._client = client
99
+ self._agent_id = agent_id
100
+ self._session_id = session_id
101
+ self.root = root
102
+ self.synchronized = synchronized
103
+ self.conflicts: set[str] = set(conflicts)
104
+
105
+ def apply_remote_patch(
106
+ self, revision: str, *, before_ack: Callable[[], None] | None = None
107
+ ) -> None:
108
+ """Download and apply one announced E2B patch, then advance the local base.
109
+
110
+ ``before_ack`` runs after the local base advanced but before the patch
111
+ is acknowledged: a detached checkpoint persisted there guarantees an
112
+ acknowledged (hence deleted) patch is never needed again.
113
+ """
114
+ content = self._client.download_agent_workspace_patch(
115
+ self._agent_id, self._session_id, revision
116
+ )
117
+ result = apply_workspace_patch(self.root, content)
118
+ new_conflicts = self._record_conflicts(result.conflicts)
119
+ # Advance the base by base+patch, never by re-reading the directory:
120
+ # a disk snapshot here would absorb not-yet-uploaded local edits into
121
+ # the base, so they would never upload (and the final sync could even
122
+ # delete them). Conflicted paths stay at their base state.
123
+ self.synchronized = apply_patch_to_snapshot(
124
+ self.synchronized, content, conflicts=result.conflicts
125
+ )
126
+ if before_ack is not None:
127
+ before_ack()
128
+ self._client.acknowledge_agent_workspace_patch(self._agent_id, self._session_id, revision)
129
+ if result.applied:
130
+ _console.print(f"[dim]workspace updated ({len(result.applied)} changed paths)[/dim]")
131
+ if new_conflicts:
132
+ paths = ", ".join(new_conflicts)
133
+ _console.print(f"[yellow]workspace sync conflict[/yellow]: {paths}")
134
+
135
+ def push_local(self) -> bool:
136
+ """Send local edits made since the last synchronized snapshot.
137
+
138
+ Returns:
139
+ Whether the synchronized base advanced (all changes were accepted).
140
+ """
141
+ try:
142
+ current = snapshot_workspace(self.root)
143
+ except WorkspaceSyncError:
144
+ return False
145
+ content = build_workspace_patch(self.synchronized.archive, current.archive)
146
+ if content is None:
147
+ return False
148
+ result = self._client.upload_agent_workspace_patch(
149
+ self._agent_id, self._session_id, content
150
+ )
151
+ new_conflicts = self._record_conflicts(result.conflicts)
152
+ if new_conflicts:
153
+ paths = ", ".join(new_conflicts)
154
+ _console.print(f"[yellow]workspace sync conflict[/yellow]: {paths}")
155
+ if result.conflicts:
156
+ # A conflicted path was rejected by E2B, so ``current`` cannot
157
+ # become the synchronized base for it; accepted sibling paths did
158
+ # land, so they advance individually.
159
+ if result.applied:
160
+ self.synchronized = advance_snapshot_paths(
161
+ self.synchronized, current, result.applied
162
+ )
163
+ return False
164
+ self.synchronized = current
165
+ return True
166
+
167
+ def try_push_local(self) -> bool:
168
+ """Push local edits, tolerating a workspace that cannot accept patches yet.
169
+
170
+ A sandbox that is still booting (or winding down) answers 409/503; the
171
+ caller's sync loop retries on its next tick, and an end falls back to
172
+ the conflict-preserving final sync. Anything else still raises.
173
+ """
174
+ try:
175
+ return self.push_local()
176
+ except PlatformError as error:
177
+ if error.status_code not in {409, 503}:
178
+ raise
179
+ _console.print("[dim]local changes will sync once the workspace is running[/dim]")
180
+ return False
181
+
182
+ def _record_conflicts(self, conflicts: Iterable[str]) -> list[str]:
183
+ """Track conflicts, returning only ones not already reported."""
184
+ fresh = [path for path in conflicts if path not in self.conflicts]
185
+ self.conflicts.update(conflicts)
186
+ return fresh
187
+
188
+ def finalize(self) -> SyncResult:
189
+ """Reconcile the terminal session's final E2B workspace into the root.
190
+
191
+ Applies the three-way merge against the last synchronized snapshot,
192
+ preserves conflicting local paths (plus the full result under
193
+ ``.wmo-conflicts/``), and acknowledges the handoff so the platform can
194
+ remove its private archive objects.
195
+ """
196
+ with _console.status("[dim]syncing E2B workspace back...[/dim]", spinner="dots"):
197
+ final_archive = self._client.download_agent_workspace(self._agent_id, self._session_id)
198
+ result = sync_workspace(
199
+ self.root,
200
+ self.synchronized,
201
+ final_archive,
202
+ protected_paths=frozenset(self.conflicts),
203
+ )
204
+ if result.conflicts:
205
+ recovery = write_conflict_archive(self.root, self._session_id, final_archive)
206
+ self._client.acknowledge_agent_workspace(self._agent_id, self._session_id)
207
+ paths = ", ".join(result.conflicts)
208
+ _console.print(
209
+ f"[red]workspace conflicts preserved locally[/red]: {paths}\n"
210
+ f"The full E2B result is saved at [bold]{recovery}[/bold]."
211
+ )
212
+ else:
213
+ self._client.acknowledge_agent_workspace(self._agent_id, self._session_id)
214
+ _console.print(f"[green]workspace synced[/green] ({len(result.applied)} changed paths)")
215
+ return result
216
+
217
+
218
+ class DetachedStartDriver:
219
+ """Start a hosted agent session, persist its reference, and return."""
220
+
221
+ def __init__(
222
+ self,
223
+ *,
224
+ client: PlatformClient,
225
+ credentials: PlatformCredentials,
226
+ state_store: SessionStateStore,
227
+ target_id: str,
228
+ name: str,
229
+ jail_root: Path | None,
230
+ task: str | None,
231
+ ) -> None:
232
+ """Store the resolved agent target and optional workspace root."""
233
+ self._client = client
234
+ self._credentials = credentials
235
+ self._store = state_store
236
+ self._target_id = target_id
237
+ self._name = name
238
+ self._jail = jail_root
239
+ self._task = task
240
+
241
+ def run(self) -> None:
242
+ """Create the session, persist the reference and checkpoint, and exit."""
243
+ try:
244
+ initial: WorkspaceSnapshot | None = None
245
+ if self._jail is not None:
246
+ with _console.status("[dim]snapshotting local workspace...[/dim]", spinner="dots"):
247
+ initial = snapshot_workspace(self._jail)
248
+ _console.print(
249
+ f"[dim]uploading {len(initial.files)} files to the platform "
250
+ "E2B workspace...[/dim]"
251
+ )
252
+ session = self._client.create_agent_session(
253
+ self._target_id,
254
+ workspace=initial.archive if initial is not None else None,
255
+ instruction=self._task,
256
+ )
257
+ workspace: WorkspaceCheckpoint | None = None
258
+ if self._jail is not None:
259
+ workspace = WorkspaceCheckpoint(root=str(self._jail))
260
+ state = DetachedSessionState(
261
+ api_url=str(self._credentials.api_url),
262
+ web_url=self._credentials.web_url,
263
+ agent_id=self._target_id,
264
+ agent_name=self._name,
265
+ session_id=session.id,
266
+ created_at=datetime.now(tz=UTC).isoformat(),
267
+ workspace=workspace,
268
+ )
269
+ try:
270
+ self._store.save(
271
+ state, base_archive=initial.archive if initial is not None else None
272
+ )
273
+ self._store.set_current(session.id)
274
+ except SessionStateError as error:
275
+ # The hosted session is already running; the user must get an
276
+ # addressable reference even though the local save failed.
277
+ msg = (
278
+ f"session {session.id} is running on the platform, but its local "
279
+ f"reference could not be saved: {error}. Control it with "
280
+ f"`wmo run --session {session.id} --attach` or end it with "
281
+ f"`wmo run --session {session.id} --end`"
282
+ )
283
+ raise typer.BadParameter(msg) from error
284
+ _console.print(
285
+ f"[green]detached E2B session started[/green] for [bold]{self._name}[/bold]\n"
286
+ f" agent {self._target_id}\n"
287
+ f" session {session.id}\n"
288
+ 'Send a message with [bold]wmo run -s "..."[/bold], attach with '
289
+ "[bold]wmo run -a[/bold], end with [bold]wmo run --end[/bold]."
290
+ )
291
+ except (WorkspacePatchError, WorkspaceSyncError, SessionStateError) as error:
292
+ raise typer.BadParameter(str(error)) from error
293
+ except PlatformError as error:
294
+ raise typer.BadParameter(str(error)) from error
295
+ finally:
296
+ self._client.close()
297
+
298
+
299
+ class AttachedCommandReader(threading.Thread):
300
+ """Terminal input for an attached session: steer, interrupt, detach, or end."""
301
+
302
+ def __init__(self, client: PlatformClient, agent_id: str, session_id: str) -> None:
303
+ """Read stdin on a daemon thread; commands post through the platform."""
304
+ super().__init__(daemon=True)
305
+ self._client = client
306
+ self._agent_id = agent_id
307
+ self._session_id = session_id
308
+ self.detach = threading.Event()
309
+ self.ended = threading.Event()
310
+
311
+ def run(self) -> None:
312
+ """Map lines to hosted commands; leaving the terminal detaches, never ends."""
313
+ try:
314
+ for raw in sys.stdin:
315
+ if self.detach.is_set():
316
+ return
317
+ line = raw.strip()
318
+ if line in {":detach", ":quit", ":q", ":exit"}:
319
+ self.detach.set()
320
+ return
321
+ if line == ":end":
322
+ # A failed end must be reported, never silently converted
323
+ # into a detach that leaves the session running; after a
324
+ # successful end the driver streams to the final handoff,
325
+ # so a following EOF must not look like a detach either.
326
+ try:
327
+ self._client.end_agent_session(self._agent_id, self._session_id)
328
+ except PlatformError as error:
329
+ _console.print(
330
+ f"[red]end failed:[/red] {error} "
331
+ "(retry [bold]:end[/bold], or run `wmo run --end` later)"
332
+ )
333
+ else:
334
+ self.ended.set()
335
+ return
336
+ elif line == ":stop":
337
+ self._post("interrupt")
338
+ elif line.startswith(":"):
339
+ # An unknown command must never reach the agent as chat.
340
+ _console.print(
341
+ f"[yellow]unknown command {line}; use :stop, :detach, or :end[/yellow]"
342
+ )
343
+ elif line:
344
+ self._post("user_message", text=line)
345
+ except OSError:
346
+ pass
347
+ finally:
348
+ # Closed stdin means the terminal went away, not that the hosted
349
+ # session should end. Ending stays explicit (:end or --end); after
350
+ # one, the driver keeps streaming to the final handoff.
351
+ if not self.ended.is_set():
352
+ self.detach.set()
353
+
354
+ def _post(self, kind: str, *, text: str | None = None) -> None:
355
+ """Post one command; a transient failure warns and keeps the reader alive."""
356
+ try:
357
+ self._client.post_agent_session_command(
358
+ self._agent_id, self._session_id, kind, text=text
359
+ )
360
+ except PlatformError as error:
361
+ _console.print(f"[red]{kind} failed:[/red] {error} (still attached; try again)")
362
+
363
+
364
+ @dataclass
365
+ class _Stream:
366
+ """Mutable streaming state threaded through the detached event loop."""
367
+
368
+ state: DetachedSessionState
369
+ persisted: bool
370
+ workspace: LiveWorkspace | None
371
+ render: bool
372
+ cursor: int = 0
373
+ pending_ack: str | None = None
374
+ pending_text: str | None = None
375
+ message_seen: bool = False
376
+ turn_idle: bool = False
377
+ foreign_patch_noted: bool = False
378
+
379
+
380
+ class DetachedCommandDriver:
381
+ """Send to, attach to, or end one hosted session from its stored reference."""
382
+
383
+ def __init__(
384
+ self,
385
+ *,
386
+ client: PlatformClient,
387
+ credentials: PlatformCredentials,
388
+ state_store: SessionStateStore,
389
+ action: SessionAction,
390
+ text: str | None,
391
+ session_override: str | None,
392
+ sink: Callable[[SessionEvent], None],
393
+ ) -> None:
394
+ """Bind one action to the resolved credentials, state store, and renderer."""
395
+ self._client = client
396
+ self._credentials = credentials
397
+ self._store = state_store
398
+ self._action = action
399
+ self._text = text
400
+ self._session_override = session_override
401
+ self._sink = sink
402
+ self._interrupts = 0
403
+ self._persisted_snapshot: WorkspaceSnapshot | None = None
404
+ self._salvage_reason: str | None = None
405
+
406
+ def run(self) -> None:
407
+ """Resolve the session, catch up, and run the requested action."""
408
+ try:
409
+ self._run()
410
+ except (WorkspacePatchError, WorkspaceSyncError, SessionStateError) as error:
411
+ raise typer.BadParameter(str(error)) from error
412
+ except PlatformError as error:
413
+ raise typer.BadParameter(str(error)) from error
414
+ finally:
415
+ self._client.close()
416
+
417
+ # -- resolution --------------------------------------------------------------------------
418
+
419
+ def _run(self) -> None:
420
+ state, persisted, remote = self._resolve()
421
+ if remote.status in TERMINAL_STATUSES:
422
+ self._handle_already_terminal(state, persisted, remote)
423
+ return
424
+ workspace = self._load_workspace(state, persisted)
425
+ stream = _Stream(
426
+ state=state,
427
+ persisted=persisted,
428
+ workspace=workspace,
429
+ render=persisted,
430
+ cursor=state.cursor,
431
+ pending_ack=state.workspace.pending_ack if state.workspace is not None else None,
432
+ )
433
+ self._retry_pending_ack(stream)
434
+ match self._action:
435
+ case "send":
436
+ self._send(stream)
437
+ case "attach":
438
+ self._attach(stream)
439
+ case "end":
440
+ self._end(stream)
441
+
442
+ def _resolve(self) -> tuple[DetachedSessionState, bool, RemoteAgentSession]:
443
+ """Resolve the addressed session to state, persistence, and remote record."""
444
+ api_url = str(self._credentials.api_url)
445
+ override = self._session_override
446
+ if override is not None:
447
+ stored = self._store.load(override)
448
+ if stored is None:
449
+ return self._resolve_remote(override, api_url)
450
+ state = stored
451
+ else:
452
+ current = self._store.current_session_id()
453
+ if current is None:
454
+ raise typer.BadParameter(
455
+ "no current session: start one with `wmo run <agent-id> --detach`, "
456
+ "or address one with --session <session-id>"
457
+ )
458
+ loaded = self._store.load(current)
459
+ if loaded is None:
460
+ raise typer.BadParameter(
461
+ f"the current session pointer references {current} but its state is "
462
+ "gone; start a new session with `wmo run <agent-id> --detach`"
463
+ )
464
+ state = loaded
465
+ if state.api_url != api_url:
466
+ stored_home = state.web_url or state.api_url
467
+ active_home = self._credentials.web_url or api_url
468
+ raise typer.BadParameter(
469
+ f"session {state.session_id} belongs to {stored_home}, but this login "
470
+ f"points at {active_home}; run `wmo login --url {stored_home}` to switch, "
471
+ "or start a new session here with `wmo run <agent-id> --detach`"
472
+ )
473
+ try:
474
+ remote = self._client.get_agent_session(state.agent_id, state.session_id)
475
+ except PlatformError as error:
476
+ if error.status_code == 404:
477
+ raise typer.BadParameter(
478
+ f"session {state.session_id} was not found on "
479
+ f"{state.web_url or state.api_url}; it may have been removed, or "
480
+ "this login may lack access (check `wmo status`)"
481
+ ) from error
482
+ raise
483
+ return state, True, remote
484
+
485
+ def _resolve_remote(
486
+ self, session_id: str, api_url: str
487
+ ) -> tuple[DetachedSessionState, bool, RemoteAgentSession]:
488
+ """Resolve a session id with no local state through the platform."""
489
+ try:
490
+ remote = self._client.resolve_agent_session(session_id)
491
+ except PlatformError as error:
492
+ if error.status_code == 404:
493
+ raise typer.BadParameter(
494
+ f"session {session_id} was not found on "
495
+ f"{self._credentials.web_url or api_url}; check --session and that "
496
+ "your login (`wmo status`) can access it"
497
+ ) from error
498
+ raise
499
+ name = remote.agent_id
500
+ with contextlib.suppress(PlatformError):
501
+ target = self._client.resolve_run_target(remote.agent_id)
502
+ name = target.display_name or target.name
503
+ state = DetachedSessionState(
504
+ api_url=api_url,
505
+ web_url=self._credentials.web_url,
506
+ agent_id=remote.agent_id,
507
+ agent_name=name,
508
+ session_id=session_id,
509
+ created_at=datetime.now(tz=UTC).isoformat(),
510
+ )
511
+ return state, False, remote
512
+
513
+ def _handle_already_terminal(
514
+ self, state: DetachedSessionState, persisted: bool, remote: RemoteAgentSession
515
+ ) -> None:
516
+ """Reconcile (--end) or explain (send/attach) an already-finished session."""
517
+ if self._action != "end":
518
+ detail = (
519
+ "reconcile its final workspace and clear it"
520
+ if persisted and state.workspace is not None
521
+ else "clear it"
522
+ )
523
+ raise typer.BadParameter(
524
+ f"session {state.session_id} is already {remote.status}; run "
525
+ f"`wmo run --session {state.session_id} --end` to {detail}, or start a "
526
+ f"new session with `wmo run {state.agent_id} --detach`"
527
+ )
528
+ workspace = self._load_workspace(state, persisted)
529
+ stream = _Stream(
530
+ state=state,
531
+ persisted=persisted,
532
+ workspace=workspace,
533
+ render=False,
534
+ cursor=state.cursor,
535
+ pending_ack=state.workspace.pending_ack if state.workspace is not None else None,
536
+ )
537
+ self._retry_pending_ack(stream)
538
+ self._finish_terminal(stream)
539
+
540
+ def _load_workspace(self, state: DetachedSessionState, persisted: bool) -> LiveWorkspace | None:
541
+ """Rehydrate the workspace transport from the persisted checkpoint."""
542
+ if not persisted or state.workspace is None:
543
+ return None
544
+ root = Path(state.workspace.root)
545
+ if not root.is_dir():
546
+ if self._action == "end":
547
+ self._salvage_reason = f"the synchronized workspace {root} no longer exists"
548
+ return None
549
+ raise typer.BadParameter(
550
+ f"the synchronized workspace {root} no longer exists; restore it, or run "
551
+ f"`wmo run --session {state.session_id} --end` to save the final "
552
+ "workspace without a local sync"
553
+ )
554
+ try:
555
+ base = self._store.load_base_archive(state)
556
+ except SessionStateError as error:
557
+ # `--end` still completes the handoff without a local sync; the
558
+ # stored error messages point other actions at exactly that.
559
+ if self._action == "end":
560
+ self._salvage_reason = str(error)
561
+ return None
562
+ raise
563
+ synchronized = snapshot_from_archive(base)
564
+ self._persisted_snapshot = synchronized
565
+ return LiveWorkspace(
566
+ self._client,
567
+ state.agent_id,
568
+ state.session_id,
569
+ root,
570
+ synchronized,
571
+ conflicts=state.workspace.conflicts,
572
+ )
573
+
574
+ # -- actions -----------------------------------------------------------------------------
575
+
576
+ def _send(self, stream: _Stream) -> None:
577
+ """Catch up, deliver one message, and stream its turn until idle."""
578
+ text = self._text if self._text is not None else ""
579
+ self._push_workspace(stream)
580
+ if self._catch_up(stream) == "terminal":
581
+ self._finish_terminal(
582
+ stream, failure_note="the session ended before the message could be sent"
583
+ )
584
+ return
585
+ self._client.post_agent_session_command(
586
+ stream.state.agent_id, stream.state.session_id, "user_message", text=text
587
+ )
588
+ stream.pending_text = text
589
+ stream.render = True
590
+ outcome = self._stream_until(stream, stop=lambda: stream.turn_idle)
591
+ if outcome == "terminal":
592
+ self._finish_terminal(stream)
593
+ return
594
+ self._persist(stream, stream.cursor)
595
+ if outcome == "detached":
596
+ _console.print("[dim]detached; the session stays alive[/dim]")
597
+ return
598
+ _console.print(f"[dim]turn finished; session {stream.state.session_id} stays alive[/dim]")
599
+
600
+ def _attach(self, stream: _Stream) -> None:
601
+ """Catch up, then stream interactively until the user detaches or ends."""
602
+ self._push_workspace(stream)
603
+ if self._catch_up(stream) == "terminal":
604
+ self._finish_terminal(stream)
605
+ return
606
+ stream.render = True
607
+ reader = AttachedCommandReader(self._client, stream.state.agent_id, stream.state.session_id)
608
+ reader.start()
609
+ _console.print(
610
+ f"[green]attached[/green] to [bold]{stream.state.agent_name}[/bold] "
611
+ f"({stream.state.session_id}). Type to steer, [bold]:stop[/bold] to interrupt, "
612
+ "[bold]:detach[/bold] to leave it running, [bold]:end[/bold] to end it."
613
+ )
614
+ outcome = self._stream_until(stream, stop=reader.detach.is_set)
615
+ if outcome == "terminal":
616
+ self._finish_terminal(stream)
617
+ return
618
+ self._persist(stream, stream.cursor)
619
+ _console.print("[dim]detached; the session stays alive[/dim]")
620
+
621
+ def _end(self, stream: _Stream) -> None:
622
+ """Catch up, push local edits, end the session, and run the final sync."""
623
+ self._push_workspace(stream)
624
+ if self._catch_up(stream) != "terminal":
625
+ remote = self._client.end_agent_session(stream.state.agent_id, stream.state.session_id)
626
+ if remote.status not in TERMINAL_STATUSES:
627
+ outcome = self._stream_until(stream, stop=lambda: False)
628
+ if outcome == "detached":
629
+ self._persist(stream, stream.cursor)
630
+ _console.print(
631
+ "[yellow]end requested; leaving before the final sync (rerun "
632
+ "`wmo run --end` to finish)[/yellow]"
633
+ )
634
+ raise typer.Exit(code=1)
635
+ self._finish_terminal(stream)
636
+
637
+ def _finish_terminal(self, stream: _Stream, *, failure_note: str | None = None) -> None:
638
+ """Run the final workspace handoff, clear local state, and report the outcome."""
639
+ conflicted = False
640
+ if stream.workspace is not None:
641
+ try:
642
+ result = stream.workspace.finalize()
643
+ conflicted = bool(result.conflicts)
644
+ except PlatformError as error:
645
+ if error.status_code != 404:
646
+ # Keep local state so `wmo run --end` can retry the handoff.
647
+ raise
648
+ _console.print(
649
+ "[yellow]no final workspace archive is available; it may already "
650
+ "have been synchronized[/yellow]"
651
+ )
652
+ elif (
653
+ stream.persisted
654
+ and stream.state.workspace is not None
655
+ and self._salvage_reason is not None
656
+ ):
657
+ self._salvage_final_workspace(stream)
658
+ terminal: RemoteAgentSession | None
659
+ try:
660
+ terminal = self._client.get_agent_session(
661
+ stream.state.agent_id, stream.state.session_id
662
+ )
663
+ except PlatformError as error:
664
+ # The record (or its agent) can vanish between the handoff and
665
+ # this read; local state must still be cleared, or it is stuck
666
+ # with no CLI path to remove it.
667
+ if error.status_code != 404:
668
+ raise
669
+ terminal = None
670
+ if stream.persisted:
671
+ self._store.delete(stream.state.session_id)
672
+ if failure_note is not None:
673
+ _console.print(f"[red]{failure_note}[/red]")
674
+ if terminal is not None and terminal.status == "failed":
675
+ _console.print(f"[red]session failed: {terminal.error or 'unknown error'}[/red]")
676
+ raise typer.Exit(code=1)
677
+ detail = (
678
+ terminal.ended_reason or terminal.status
679
+ if terminal is not None
680
+ else "no longer visible on the platform"
681
+ )
682
+ _console.print(f"[dim]session ended ({detail})[/dim]")
683
+ if failure_note is not None:
684
+ raise typer.Exit(code=1)
685
+ if conflicted:
686
+ raise typer.Exit(code=2)
687
+
688
+ def _salvage_final_workspace(self, stream: _Stream) -> None:
689
+ """Save the final archive without a local sync when the checkpoint is unusable.
690
+
691
+ The handoff is still acknowledged so the platform can remove its
692
+ private archive object; the user's data lands as a recovery archive
693
+ (under the workspace root when it exists, else in WMO state, where it
694
+ survives the state cleanup).
695
+ """
696
+ state = stream.state
697
+ try:
698
+ content = self._client.download_agent_workspace(state.agent_id, state.session_id)
699
+ except PlatformError as error:
700
+ if error.status_code != 404:
701
+ # Keep local state so `wmo run --end` can retry the handoff.
702
+ raise
703
+ _console.print(
704
+ "[yellow]no final workspace archive is available; it may already "
705
+ "have been synchronized[/yellow]"
706
+ )
707
+ return
708
+ workspace_state = state.workspace
709
+ root = Path(workspace_state.root) if workspace_state is not None else None
710
+ if root is not None and root.is_dir():
711
+ recovery = write_conflict_archive(root, state.session_id, content)
712
+ else:
713
+ recovery = self._store.write_recovery_archive(state.session_id, content)
714
+ self._client.acknowledge_agent_workspace(state.agent_id, state.session_id)
715
+ _console.print(
716
+ f"[yellow]final workspace saved without a local sync "
717
+ f"({self._salvage_reason}).[/yellow]\n"
718
+ f"The full E2B result is at [bold]{recovery}[/bold]."
719
+ )
720
+
721
+ def _retry_pending_ack(self, stream: _Stream) -> None:
722
+ """Deliver a patch acknowledgement a previous invocation could not send."""
723
+ workspace_state = stream.state.workspace
724
+ if not stream.persisted or workspace_state is None or workspace_state.pending_ack is None:
725
+ return
726
+ revision = workspace_state.pending_ack
727
+ try:
728
+ self._client.acknowledge_agent_workspace_patch(
729
+ stream.state.agent_id, stream.state.session_id, revision
730
+ )
731
+ except PlatformError as error:
732
+ # 404 means the object is already gone (acknowledged after all, or
733
+ # removed with the session); anything else stays retryable.
734
+ if error.status_code != 404:
735
+ raise
736
+ stream.pending_ack = None
737
+ self._persist(stream, stream.cursor)
738
+
739
+ # -- event loop --------------------------------------------------------------------------
740
+
741
+ def _catch_up(self, stream: _Stream) -> str:
742
+ """Apply everything durable that happened while no CLI process ran.
743
+
744
+ Returns:
745
+ ``"terminal"`` when the session finished, otherwise ``"ready"``.
746
+ """
747
+ while True:
748
+ page = self._poll(stream)
749
+ if page.status in TERMINAL_STATUSES:
750
+ return "terminal"
751
+ if not page.events:
752
+ return "ready"
753
+
754
+ def _stream_until(self, stream: _Stream, *, stop: Callable[[], bool]) -> str:
755
+ """Poll, render, and synchronize until ``stop``, terminal state, or detach.
756
+
757
+ Returns:
758
+ ``"terminal"``, ``"stopped"``, or ``"detached"`` (double Ctrl-C).
759
+ """
760
+ last_push = time.monotonic()
761
+ last_probe = time.monotonic()
762
+ while True:
763
+ try:
764
+ page = self._poll(stream)
765
+ if page.status in TERMINAL_STATUSES:
766
+ return "terminal"
767
+ if stop():
768
+ return "stopped"
769
+ now = time.monotonic()
770
+ if stream.workspace is not None and now - last_push >= _WORKSPACE_SYNC_TICK_S:
771
+ self._push_workspace(stream)
772
+ last_push = now
773
+ if now - last_probe >= _STALE_PROBE_S:
774
+ # The detail read lazily reconciles a dead driver so the
775
+ # next events poll reports the truthful terminal status.
776
+ with contextlib.suppress(PlatformError):
777
+ self._client.get_agent_session(
778
+ stream.state.agent_id, stream.state.session_id
779
+ )
780
+ last_probe = now
781
+ time.sleep(_POLL_INTERVAL_S)
782
+ except KeyboardInterrupt:
783
+ self._interrupts += 1
784
+ if self._interrupts >= 2:
785
+ return "detached"
786
+ # The next Ctrl-C frequently lands inside this handler (the
787
+ # print or the HTTP post); it must detach, not crash out.
788
+ try:
789
+ _console.print("\n[yellow]interrupting (press Ctrl-C again to detach)[/yellow]")
790
+ with contextlib.suppress(PlatformError):
791
+ self._client.post_agent_session_command(
792
+ stream.state.agent_id, stream.state.session_id, "interrupt"
793
+ )
794
+ except KeyboardInterrupt:
795
+ return "detached"
796
+
797
+ def _poll(self, stream: _Stream) -> RemoteAgentEventPage:
798
+ """Fetch one page after the cursor, process it, and persist the checkpoint."""
799
+ page = self._client.list_agent_session_events(
800
+ stream.state.agent_id, stream.state.session_id, after=stream.cursor
801
+ )
802
+ for event in page.events:
803
+ self._handle_event(stream, event)
804
+ # Advance per event, not only per page: a Ctrl-C landing mid-page
805
+ # must resume after the events already processed (re-fetching a
806
+ # patch event whose object was acknowledged would 404).
807
+ stream.cursor = event.seq
808
+ stream.cursor = page.last_seq
809
+ self._persist(stream, page.last_seq)
810
+ return page
811
+
812
+ def _handle_event(self, stream: _Stream, event: RemoteAgentSessionEvent) -> None:
813
+ """Apply one durable event: workspace transport, turn tracking, rendering."""
814
+ if event.kind == "workspace_patch":
815
+ if stream.workspace is None:
816
+ if not stream.foreign_patch_noted:
817
+ _console.print(
818
+ "[dim](workspace patches are synchronized by the launching CLI; "
819
+ "skipping)[/dim]"
820
+ )
821
+ stream.foreign_patch_noted = True
822
+ return
823
+ revision = patch_revision(event)
824
+
825
+ def checkpoint_before_ack() -> None:
826
+ stream.cursor = event.seq
827
+ stream.pending_ack = revision
828
+ self._persist(stream, event.seq)
829
+
830
+ stream.workspace.apply_remote_patch(revision, before_ack=checkpoint_before_ack)
831
+ stream.pending_ack = None
832
+ self._persist(stream, stream.cursor)
833
+ return
834
+ if event.kind == "status":
835
+ detail = event.payload.get("message") or event.payload.get("status")
836
+ if detail and stream.render:
837
+ _console.print(f"[dim]({detail})[/dim]")
838
+ return
839
+ if event.kind == "user_message" and stream.pending_text is not None:
840
+ # Turn attribution matches the echoed text among events after the
841
+ # send-time cursor. The command API cannot correlate a command id
842
+ # to its transcript echo, so an identical message posted by
843
+ # another actor in the same window can end the stream one turn
844
+ # early; the session itself is unaffected (a known approximation).
845
+ if event.payload.get("text") == stream.pending_text:
846
+ stream.message_seen = True
847
+ if event.kind == "state" and stream.message_seen and event.payload.get("status") == "idle":
848
+ stream.turn_idle = True
849
+ if stream.render:
850
+ self._sink(SessionEvent(kind=event.kind, payload=event.payload))
851
+
852
+ # -- checkpointing -----------------------------------------------------------------------
853
+
854
+ def _push_workspace(self, stream: _Stream) -> None:
855
+ """Upload local edits made since the checkpoint, then persist it."""
856
+ if stream.workspace is None:
857
+ return
858
+ stream.workspace.try_push_local()
859
+ self._persist(stream, stream.cursor)
860
+
861
+ def _persist(self, stream: _Stream, cursor: int) -> None:
862
+ """Advance the durable checkpoint (cursor, conflicts, base snapshot)."""
863
+ if not stream.persisted:
864
+ return
865
+ state = stream.state
866
+ workspace_state = state.workspace
867
+ archive: bytes | None = None
868
+ if workspace_state is not None:
869
+ update: dict[str, tuple[str, ...] | str | None] = {"pending_ack": stream.pending_ack}
870
+ if stream.workspace is not None:
871
+ update["conflicts"] = tuple(sorted(stream.workspace.conflicts))
872
+ if stream.workspace.synchronized is not self._persisted_snapshot:
873
+ archive = stream.workspace.synchronized.archive
874
+ workspace_state = workspace_state.model_copy(update=update)
875
+ if cursor == state.cursor and workspace_state == state.workspace and archive is None:
876
+ return
877
+ updated = state.model_copy(update={"cursor": cursor, "workspace": workspace_state})
878
+ stream.state = self._store.save(updated, base_archive=archive)
879
+ if stream.workspace is not None:
880
+ self._persisted_snapshot = stream.workspace.synchronized