pcode 0.1.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 (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/remote_print.py ADDED
@@ -0,0 +1,125 @@
1
+ """`pcode --attach HOST --print PROMPT`: one message or command for a running host, no editor.
2
+
3
+ It attaches like a terminal, sends the message (queued behind whatever the host
4
+ is already doing) or the slash command, writes what that produces the way a
5
+ local `--print` writes its turn (the reply on stdout, the rest on stderr), and
6
+ detaches. The host keeps running, and every other terminal sees the turn too.
7
+
8
+ The host tells this caller alone when its message's turn starts, or that it
9
+ was dropped, in order with everything else it shows (`pcode.host._Owner`), so
10
+ a turn another terminal started, or one already running, is never mistaken
11
+ for it. Notes other terminals cause while it runs are written all the same.
12
+ """
13
+
14
+ import asyncio
15
+ import sys
16
+
17
+ from pcode.controller import TERMINAL_COMMANDS
18
+ from pcode.gateway import HeadlessView, attached, host_call, submit
19
+ from pcode.host_protocol import HostEntry
20
+ from pcode.remote import stop_entry
21
+ from pcode.runtime import PlanUpdated
22
+ from pcode.stream_display import PrintedReply
23
+
24
+
25
+ class PrintView(HeadlessView):
26
+ """The view a `--print` attach gives the host: its own turn or command, written out."""
27
+
28
+ def __init__(self, transcript, reply: PrintedReply) -> None:
29
+ super().__init__()
30
+ self.transcript = transcript
31
+ self.reply = reply
32
+ # Busy while the host works, on this message or the turns ahead of it.
33
+ self.tab = reply.tab_progress(self.activity)
34
+
35
+ def gone(self, *, stopped: bool) -> None:
36
+ if self.ended:
37
+ self.transcript.note("The session ended, and its host stopped.")
38
+ else:
39
+ self.transcript.error(
40
+ "The session host stopped before this finished."
41
+ if stopped
42
+ else "The session host went away."
43
+ )
44
+
45
+ def event(self, event) -> None:
46
+ self.tab.turn_event()
47
+ if isinstance(event, PlanUpdated):
48
+ self.activity.plan = event.items
49
+ self.reply.event(event)
50
+
51
+ def retry(self, text: str) -> None:
52
+ self.tab.turn_retry()
53
+ self.reply.settle()
54
+ self.transcript.note(text)
55
+
56
+ def settle(self) -> None:
57
+ self.reply.settle()
58
+
59
+ def scrollback(self, name: str, args: tuple, kwargs: dict) -> None:
60
+ getattr(self.transcript, name)(*args, **kwargs)
61
+
62
+
63
+ async def print_to_host(entry: HostEntry, prompt: str, *, transcript, present, stdout=None) -> bool:
64
+ """Send `prompt` to the host at `entry`, write what it produces; returns whether it worked."""
65
+ prompt = prompt.strip()
66
+ name = prompt.split(maxsplit=1)[0] if prompt.startswith("/") else ""
67
+ if name == "/stop":
68
+ return await _stop(entry, prompt, transcript)
69
+ if name in TERMINAL_COMMANDS:
70
+ transcript.error(f"{name} needs the interactive terminal: pcode --attach {entry.id}")
71
+ return False
72
+ reply = PrintedReply(
73
+ sys.stdout if stdout is None else stdout, transcript=transcript, present=present
74
+ )
75
+ view = PrintView(transcript, reply)
76
+ async with view.tab.shown():
77
+ return await _send(entry, prompt, name, view)
78
+
79
+
80
+ async def _send(entry: HostEntry, prompt: str, name: str, view: PrintView) -> bool:
81
+ """Attach, send the message or command, and write what it produces."""
82
+ transcript, reply = view.transcript, view.reply
83
+ async with attached(entry, view) as controller:
84
+ if controller.startup_error is not None:
85
+ transcript.error(str(controller.startup_error), title="Agent startup failed")
86
+ return False
87
+ if name:
88
+ view.command = True
89
+ ran = await host_call(controller, "run", prompt, view)
90
+ view.command = False
91
+ if ran is False and not view.failed:
92
+ transcript.error(f"{name} did not run: the host's queue was cleared first.")
93
+ return False
94
+ # A host that went away has said whether that was a failure.
95
+ return not view.failed
96
+ try:
97
+ await submit(controller, prompt, view)
98
+ except asyncio.CancelledError:
99
+ # Ctrl+C only detaches: the host's own cancel would also clear
100
+ # every other terminal's queue. Attach to stop the turn there.
101
+ reply.settle()
102
+ where = "keeps running" if view.turn else "is still queued"
103
+ transcript.note(f"Detached; the message {where} in host {entry.id}.")
104
+ raise
105
+ if view.dropped:
106
+ # Startup can fail after this attached, and then drops the queue.
107
+ error = controller.startup_error
108
+ transcript.error(
109
+ str(error) if error else "The host dropped this message before it ran.",
110
+ title="Agent startup failed" if error else "Error",
111
+ )
112
+ return not view.failed
113
+
114
+
115
+ async def _stop(entry: HostEntry, prompt: str, transcript) -> bool:
116
+ """`/stop`: end the host; it tidies its own worktree, as nobody is here to ask."""
117
+ if prompt != "/stop":
118
+ transcript.error("Usage: /stop")
119
+ return False
120
+ await stop_entry(entry)
121
+ line = f"Stopped session host {entry.id}."
122
+ if entry.session_id:
123
+ line += f" Continue with: pcode --continue {entry.session_id}"
124
+ transcript.note(line)
125
+ return True
@@ -0,0 +1,237 @@
1
+ """The fixed execution profile for a session host nobody is watching.
2
+
3
+ A host started by a remote control (`pcode --email-listen`) runs turns with no
4
+ one at the terminal to stop them, and pcode has no approval step. So the
5
+ listener chooses a profile locally, before any message arrives, and nothing
6
+ sent remotely can change it:
7
+
8
+ - the bundled `sandbox` extension is on whatever `extensions_off` says, its
9
+ shell sandbox cannot be switched off, and it also refuses reads of the
10
+ listener's own state and credentials, and keychain lookups from the shell;
11
+ - the host's environment is built from an allowlist, not inherited whole;
12
+ - MCP servers stay off unless the profile opts in (they run unsandboxed);
13
+ - each turn has a wall-clock limit and a budget of model requests and tool
14
+ calls, shared with its sub-agents; reaching one ends the turn with a status
15
+ that says so;
16
+ - the session always gets a fresh worktree, which is kept when the host stops.
17
+
18
+ `spawn_host(profile=...)` hands the profile to `pcode.host`, which calls
19
+ `activate` before anything else is built; the rest of pcode asks `active()`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import time
26
+ from collections.abc import Mapping
27
+ from dataclasses import asdict, dataclass, field
28
+
29
+ # What a turn ending at a limit says, first in its message: the gateway reads
30
+ # a turn that failed or was cancelled with this as one that hit a limit.
31
+ LIMIT_PREFIX = "Turn limit reached"
32
+
33
+ # Exact names a remote host may inherit. Provider credentials come from
34
+ # `pcode.models.ENV_PROVIDERS` as well (see `allowed_env_names`).
35
+ _ENV_NAMES = frozenset(
36
+ {
37
+ "PATH",
38
+ "HOME",
39
+ "USER",
40
+ "LOGNAME",
41
+ "SHELL",
42
+ "TMPDIR",
43
+ "TERM",
44
+ "LANG",
45
+ "LANGUAGE",
46
+ "TZ",
47
+ "XDG_CONFIG_HOME",
48
+ "XDG_CACHE_HOME",
49
+ "XDG_STATE_HOME",
50
+ "XDG_DATA_HOME",
51
+ "ANTHROPIC_API_KEY",
52
+ "ANTHROPIC_AUTH_TOKEN",
53
+ "ANTHROPIC_BASE_URL",
54
+ "OPENAI_BASE_URL",
55
+ "AZURE_OPENAI_ENDPOINT",
56
+ "AWS_REGION",
57
+ "AWS_DEFAULT_REGION",
58
+ # Bedrock's key id is a provider variable; the rest of the credential with it.
59
+ "AWS_SECRET_ACCESS_KEY",
60
+ "AWS_SESSION_TOKEN",
61
+ "SSL_CERT_FILE",
62
+ "SSL_CERT_DIR",
63
+ "REQUESTS_CA_BUNDLE",
64
+ "PCODE_CONFIG_DIR",
65
+ "PCODE_SESSION_DIR",
66
+ "PCODE_CREDENTIALS_FILE",
67
+ "PCODE_CODEX_CREDENTIALS_FILE",
68
+ "PCODE_MERIDIAN_API_KEY",
69
+ "PCODE_MERIDIAN_BASE_URL",
70
+ "PCODE_MERIDIAN_MANAGED",
71
+ "PCODE_ANTHROPIC_AUTH",
72
+ "PCODE_LLM_PROXY",
73
+ "PCODE_MCP_CONFIG",
74
+ "PYDANTIC_AI_NO_BANNER",
75
+ # Which pcode the host imports, as for the listener.
76
+ "PYTHONPATH",
77
+ "VIRTUAL_ENV",
78
+ }
79
+ )
80
+ _ENV_PREFIXES = ("LC_",)
81
+
82
+ # Mach services the shell sandbox may not reach: the keychain, where the
83
+ # listener keeps its mailbox credential.
84
+ KEYCHAIN_SERVICES = (
85
+ "com.apple.SecurityServer",
86
+ "com.apple.securityd",
87
+ "com.apple.security.keychaind",
88
+ )
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class RemoteProfile:
93
+ """What a remote host may do; serialised onto its command line (no secrets)."""
94
+
95
+ # Extra unreadable paths (globs, `~` allowed): the listener's state, credentials.
96
+ deny_read: tuple[str, ...] = ()
97
+ # Default MCP servers start, as in a local session.
98
+ mcp: bool = False
99
+ # Per turn, sub-agents included; 0 is no limit.
100
+ turn_minutes: float = 30.0
101
+ max_requests: int = 100
102
+ max_tool_calls: int = 100
103
+ # The commit or branch the session's worktree starts from; None: the mainline.
104
+ base: str | None = None
105
+ # Who started it, for `pcode --hosts` and the host log.
106
+ origin: str = "remote"
107
+
108
+ def to_json(self) -> str:
109
+ return json.dumps(asdict(self), sort_keys=True)
110
+
111
+ @classmethod
112
+ def from_json(cls, text: str) -> RemoteProfile:
113
+ data = json.loads(text)
114
+ if not isinstance(data, dict):
115
+ raise ValueError("A remote profile must be a JSON object.")
116
+ known = {name for name in cls.__dataclass_fields__}
117
+ if unknown := set(data) - known:
118
+ raise ValueError(f"Unknown remote profile keys: {', '.join(sorted(unknown))}")
119
+ if "deny_read" in data:
120
+ data["deny_read"] = tuple(str(entry) for entry in data["deny_read"])
121
+ return cls(**data)
122
+
123
+ def describe(self) -> list[str]:
124
+ """The profile as lines for a terminal or an email."""
125
+
126
+ def limit(value, unit: str) -> str:
127
+ return f"{value:g} {unit}" if value else "no limit"
128
+
129
+ return [
130
+ "Sandbox: on (writes confined to the session worktree and caches; "
131
+ "credentials and the listener's state unreadable; no keychain)",
132
+ "Worktree: a fresh one per session, kept afterwards; nothing is merged",
133
+ "Environment: allowlisted (provider keys, PATH, HOME, locale) only",
134
+ f"MCP servers: {'default servers start' if self.mcp else 'off'}",
135
+ "Per turn: "
136
+ + ", ".join(
137
+ (
138
+ limit(self.turn_minutes, "min"),
139
+ limit(self.max_requests, "model requests"),
140
+ limit(self.max_tool_calls, "tool calls"),
141
+ )
142
+ ),
143
+ "Project extensions and worktree-setup: only if the repository is already trusted",
144
+ ]
145
+
146
+
147
+ _ACTIVE: RemoteProfile | None = None
148
+
149
+
150
+ def activate(profile: RemoteProfile | None) -> None:
151
+ """Apply `profile` to this process; a host calls it once, before building anything."""
152
+ global _ACTIVE
153
+ _ACTIVE = profile
154
+ BUDGET.profile = profile
155
+ BUDGET.start()
156
+
157
+
158
+ def active() -> RemoteProfile | None:
159
+ return _ACTIVE
160
+
161
+
162
+ def allowed_env_names(environ: Mapping[str, str]) -> set[str]:
163
+ from pcode.models import ENV_PROVIDERS
164
+
165
+ provider = {name for groups in ENV_PROVIDERS.values() for group in groups for name in group}
166
+ names = _ENV_NAMES | provider
167
+ return {
168
+ name for name in environ if name in names or any(name.startswith(p) for p in _ENV_PREFIXES)
169
+ }
170
+
171
+
172
+ def scrubbed_env(environ: Mapping[str, str]) -> dict[str, str]:
173
+ """The environment a remote host starts with: allowlisted names only."""
174
+ return {name: environ[name] for name in sorted(allowed_env_names(environ))}
175
+
176
+
177
+ class TurnLimitReached(RuntimeError):
178
+ """A remote turn used up a budget. Not transient, so no retry spends more."""
179
+
180
+ # Its message is fixed text: `error_message` shows it as is.
181
+ sanitized = True
182
+
183
+
184
+ @dataclass
185
+ class TurnBudget:
186
+ """What the running turn has used, across the agent and every sub-agent."""
187
+
188
+ profile: RemoteProfile | None = None
189
+ requests: int = 0
190
+ tool_calls: int = 0
191
+ started: float = field(default_factory=time.monotonic)
192
+ # Set once a limit is hit, so every later request or call fails at once.
193
+ reached: str = ""
194
+
195
+ def start(self) -> None:
196
+ self.requests = self.tool_calls = 0
197
+ self.started = time.monotonic()
198
+ self.reached = ""
199
+
200
+ def _check(self) -> None:
201
+ if self.reached:
202
+ raise TurnLimitReached(self.reached)
203
+
204
+ def _over(self, reason: str) -> None:
205
+ self.reached = f"{LIMIT_PREFIX}: {reason}. The turn was stopped."
206
+ raise TurnLimitReached(self.reached)
207
+
208
+ def charge_request(self) -> None:
209
+ self._check()
210
+ profile = self.profile
211
+ if profile is None:
212
+ return
213
+ if profile.turn_minutes and time.monotonic() - self.started > profile.turn_minutes * 60:
214
+ self._over(f"{profile.turn_minutes:g} minutes")
215
+ self.requests += 1
216
+ if profile.max_requests and self.requests > profile.max_requests:
217
+ self._over(f"{profile.max_requests} model requests")
218
+
219
+ def charge_tool_call(self) -> None:
220
+ self._check()
221
+ profile = self.profile
222
+ if profile is None:
223
+ return
224
+ self.tool_calls += 1
225
+ if profile.max_tool_calls and self.tool_calls > profile.max_tool_calls:
226
+ self._over(f"{profile.max_tool_calls} tool calls")
227
+
228
+ def expire(self) -> str:
229
+ """The wall clock ran out: later requests fail; returns what to show."""
230
+ profile = self.profile
231
+ minutes = profile.turn_minutes if profile is not None else 0
232
+ self.reached = f"{LIMIT_PREFIX}: {minutes:g} minutes. The turn was stopped."
233
+ return self.reached
234
+
235
+
236
+ # One per process: a host runs one turn at a time, and sub-agents share it.
237
+ BUDGET = TurnBudget()
@@ -0,0 +1,319 @@
1
+ """Read-only replay of journaled display events, never of model/tool execution."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import stat
7
+ import time
8
+ from collections import Counter, deque
9
+ from dataclasses import dataclass
10
+ from functools import partial
11
+ from pathlib import Path
12
+
13
+ from pydantic import TypeAdapter, ValidationError
14
+
15
+ from pcode import runtime
16
+ from pcode.stream_display import present_events, present_stream_event
17
+ from pcode.ui import Activity, TerminalOutput, Transcript
18
+
19
+ MAX_RECORD_BYTES = 8 * 1024 * 1024
20
+ EVENT_TYPES = (
21
+ runtime.Message,
22
+ runtime.TextDelta,
23
+ runtime.Thinking,
24
+ runtime.ThinkingDelta,
25
+ runtime.ToolStarted,
26
+ runtime.ToolSummary,
27
+ runtime.EditCompleted,
28
+ runtime.CacheBust,
29
+ runtime.RunStatus,
30
+ runtime.PlanUpdated,
31
+ )
32
+ ADAPTERS = {cls.__name__: TypeAdapter(cls) for cls in EVENT_TYPES}
33
+ BOUNDARIES = {"turn_started", "turn_completed", "turn_cancelled", "turn_failed"}
34
+ METADATA = {"tree_selected", "compaction_checkpoint"}
35
+
36
+
37
+ class EndOnlyOutput(TerminalOutput):
38
+ """Experimental comparison only: parse at semantic finish boundaries, not per line."""
39
+
40
+ def _commit_blocks(self, *, thinking: bool = False) -> None:
41
+ pass
42
+
43
+
44
+ class JournalSnapshot:
45
+ """Freeze the readable byte prefix without opening/locking/modifying a SavedSession."""
46
+
47
+ def __init__(self, path: Path):
48
+ if path.parent.is_symlink() or path.is_symlink():
49
+ raise ValueError("symlinked journals are not supported")
50
+ fd = os.open(
51
+ path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
52
+ )
53
+ try:
54
+ info = os.fstat(fd)
55
+ if not stat.S_ISREG(info.st_mode):
56
+ raise ValueError("journal must be a regular file")
57
+ self.size = info.st_size
58
+ self.file = os.fdopen(fd, "rb")
59
+ except BaseException:
60
+ os.close(fd)
61
+ raise
62
+ self.expected_digest = None
63
+
64
+ def close(self):
65
+ self.file.close()
66
+
67
+ def records(self):
68
+ # A buffered seek within the previous read buffer can return stale bytes
69
+ # after an in-place edit. SEEK_END invalidates that buffer before rereading.
70
+ self.file.seek(0, os.SEEK_END)
71
+ self.file.seek(0)
72
+ remaining = self.size
73
+ digest = hashlib.sha256()
74
+ self.skipped = Counter()
75
+ while remaining:
76
+ line = self.file.readline(min(remaining, MAX_RECORD_BYTES + 1))
77
+ if not line:
78
+ raise ValueError("journal was truncated during replay")
79
+ remaining -= len(line)
80
+ digest.update(line)
81
+ if len(line) > MAX_RECORD_BYTES:
82
+ # Drain one oversized record in bounded chunks without decoding it.
83
+ while remaining and not line.endswith(b"\n"):
84
+ line = self.file.readline(min(remaining, MAX_RECORD_BYTES + 1))
85
+ if not line:
86
+ raise ValueError("journal was truncated during replay")
87
+ remaining -= len(line)
88
+ digest.update(line)
89
+ self.skipped["oversized"] += 1
90
+ continue
91
+ try:
92
+ record = json.loads(line)
93
+ except (ValueError, UnicodeDecodeError):
94
+ self.skipped["malformed"] += 1
95
+ continue
96
+ if not isinstance(record, dict):
97
+ self.skipped["malformed"] += 1
98
+ elif record.get("version", 1) != 1:
99
+ self.skipped["unsupported_version"] += 1
100
+ else:
101
+ yield record
102
+ self.digest = digest.hexdigest()
103
+ if self.expected_digest is not None and self.digest != self.expected_digest:
104
+ raise ValueError("journal changed between replay passes")
105
+ self.expected_digest = self.digest
106
+
107
+
108
+ @dataclass
109
+ class ReplaySettings:
110
+ show_thinking: bool = True
111
+ command_scrollback: bool = False
112
+ start_turn: int = 1
113
+ max_turns: int | None = None
114
+
115
+
116
+ async def replay_journal(
117
+ snapshot: JournalSnapshot, output: TerminalOutput, settings: ReplaySettings
118
+ ):
119
+ """Measure shared live presentation code; no prompt app, backend, or session writer."""
120
+ activity = Activity(thinking_mode="scrollback" if settings.show_thinking else "off")
121
+ transcript = Transcript(
122
+ output.console,
123
+ activity=activity,
124
+ preferences={"show_commands": "on" if settings.command_scrollback else "off"},
125
+ detected_theme="dark",
126
+ )
127
+ transcript.output = output
128
+ # Edits still render and are retained by Transcript; no diff-browser history is needed.
129
+ present = partial(
130
+ present_events, activity=activity, transcript=transcript, edits=deque(maxlen=0)
131
+ )
132
+ counts = Counter()
133
+ skipped = Counter()
134
+ turns = []
135
+ current = None
136
+ turn_index = 0
137
+ open_turn = False
138
+ render_cpu = render_wall = 0.0
139
+ started, cpu_started = time.perf_counter(), time.process_time()
140
+
141
+ def selected(index):
142
+ return index >= settings.start_turn and (
143
+ settings.max_turns is None or index < settings.start_turn + settings.max_turns
144
+ )
145
+
146
+ async def finish_turn():
147
+ nonlocal open_turn, render_cpu, render_wall
148
+ if not open_turn:
149
+ return
150
+ before, cpu_before = time.perf_counter(), time.process_time()
151
+ output.end_turn()
152
+ activity.tools.clear()
153
+ activity.status = ""
154
+ await output.flush()
155
+ render_wall += time.perf_counter() - before
156
+ cpu = time.process_time() - cpu_before
157
+ render_cpu += cpu
158
+ if current is not None:
159
+ current["render_cpu_seconds"] += cpu
160
+ open_turn = False
161
+
162
+ for record in snapshot.records():
163
+ kind = record.get("kind")
164
+ if not isinstance(kind, str):
165
+ skipped["malformed"] += 1
166
+ continue
167
+ if kind in METADATA:
168
+ skipped["metadata"] += 1
169
+ continue
170
+ if kind not in ADAPTERS and kind not in BOUNDARIES:
171
+ skipped["unknown_kind"] += 1
172
+ continue
173
+ if kind == "turn_started":
174
+ # Also settles an interrupted attempt without a closing journal record.
175
+ await finish_turn()
176
+ turn_index += 1
177
+ current = None
178
+ if selected(turn_index):
179
+ current = dict(turn=turn_index, events=0, characters=0, render_cpu_seconds=0.0)
180
+ turns.append(current)
181
+ elif turn_index == 0:
182
+ # Legacy journals can contain display events before a turn marker.
183
+ turn_index = 1
184
+ if selected(turn_index):
185
+ current = dict(turn=turn_index, events=0, characters=0, render_cpu_seconds=0.0)
186
+ turns.append(current)
187
+ if not selected(turn_index):
188
+ skipped["outside_turn_range"] += 1
189
+ continue
190
+ event = None
191
+ if kind in ADAPTERS:
192
+ try:
193
+ event = ADAPTERS[kind].validate_python(record)
194
+ except ValidationError:
195
+ skipped["invalid_event"] += 1
196
+ continue
197
+ elif kind == "turn_started" and not isinstance(record.get("prompt", ""), str):
198
+ skipped["invalid_event"] += 1
199
+ continue
200
+ counts[kind] += 1
201
+ if current is not None:
202
+ current["events"] += 1
203
+ if kind in {"TextDelta", "ThinkingDelta", "Message", "Thinking"}:
204
+ current["characters"] += len(getattr(event, "text", getattr(event, "markdown", "")))
205
+ if kind in BOUNDARIES and kind != "turn_started":
206
+ await finish_turn()
207
+ continue
208
+ open_turn = True
209
+ before, cpu_before = time.perf_counter(), time.process_time()
210
+ if kind == "turn_started":
211
+ output.begin_turn(record.get("prompt", ""))
212
+ else:
213
+ present_stream_event(
214
+ event, output=output, transcript=transcript, activity=activity, present=present
215
+ )
216
+ await output.flush()
217
+ elapsed, cpu = time.perf_counter() - before, time.process_time() - cpu_before
218
+ render_wall += elapsed
219
+ render_cpu += cpu
220
+ if current is not None:
221
+ current["render_cpu_seconds"] += cpu
222
+ await finish_turn()
223
+ return dict(
224
+ snapshot_bytes=snapshot.size,
225
+ journal_sha256=snapshot.digest,
226
+ turns=len(turns),
227
+ event_counts=dict(counts),
228
+ skipped_records=dict(snapshot.skipped + skipped),
229
+ render_cpu_seconds=render_cpu,
230
+ render_wall_seconds=render_wall,
231
+ cpu_seconds=time.process_time() - cpu_started,
232
+ wall_seconds=time.perf_counter() - started,
233
+ top_turns=sorted(turns, key=lambda item: item["render_cpu_seconds"], reverse=True)[:10],
234
+ )
235
+
236
+
237
+ # Real journals pause for minutes while a tool runs or the user types; a live
238
+ # replay is about render cost, so no single gap waits longer than this.
239
+ MAX_GAP_SECONDS = 2.0
240
+
241
+
242
+ @dataclass
243
+ class JournalTurn:
244
+ prompt: str
245
+ events: list # (seconds since the previous event, display event)
246
+
247
+
248
+ def journal_turns(snapshot: JournalSnapshot) -> list[JournalTurn]:
249
+ """Split a journal into prompts and their timestamped display events."""
250
+ turns: list[JournalTurn] = []
251
+ previous = None
252
+ for record in snapshot.records():
253
+ kind = record.get("kind")
254
+ if kind == "turn_started":
255
+ prompt = record.get("prompt", "")
256
+ turns.append(JournalTurn(prompt if isinstance(prompt, str) else "", []))
257
+ previous = _record_time(record)
258
+ continue
259
+ if kind not in ADAPTERS:
260
+ continue
261
+ if not turns:
262
+ turns.append(JournalTurn("", []))
263
+ try:
264
+ event = ADAPTERS[kind].validate_python(record)
265
+ except ValidationError:
266
+ continue
267
+ stamp = _record_time(record)
268
+ gap = 0.0
269
+ if stamp is not None and previous is not None:
270
+ gap = min(max(0.0, (stamp - previous).total_seconds()), MAX_GAP_SECONDS)
271
+ if stamp is not None:
272
+ previous = stamp
273
+ turns[-1].events.append((gap, event))
274
+ return [turn for turn in turns if turn.events]
275
+
276
+
277
+ def _record_time(record):
278
+ from datetime import datetime
279
+
280
+ value = record.get("time")
281
+ if not isinstance(value, str):
282
+ return None
283
+ try:
284
+ return datetime.fromisoformat(value)
285
+ except ValueError:
286
+ return None
287
+
288
+
289
+ class JournalRuntime:
290
+ """A runtime whose every ``stream`` call plays back the next journaled turn.
291
+
292
+ Only what ``PreviewApp.run_live`` reads is here; nothing contacts a model.
293
+ """
294
+
295
+ session = None
296
+ recovery_blocked = None
297
+ agent = None
298
+ inspections = None
299
+
300
+ def __init__(self, turns: list[JournalTurn], speed: float = 1.0):
301
+ self.turns = list(turns)
302
+ self.speed = speed
303
+ self.events_played = 0
304
+
305
+ async def stream(self, text):
306
+ import asyncio
307
+
308
+ turn = self.turns.pop(0)
309
+ for gap, event in turn.events:
310
+ if self.speed > 0 and gap > 0:
311
+ await asyncio.sleep(gap / self.speed)
312
+ else:
313
+ # Keep the editor responsive even at full speed.
314
+ await asyncio.sleep(0)
315
+ self.events_played += 1
316
+ yield event
317
+
318
+ def close(self) -> None:
319
+ pass