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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
pcode/remote_profile.py
ADDED
|
@@ -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
|