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
@@ -0,0 +1,61 @@
1
+ """Exceptions for failed `claude:` requests, and what to tell the user about them."""
2
+
3
+ from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError
4
+
5
+ MISSING_SDK = (
6
+ "claude: models need pcode's optional `claude` extra, which is not installed. "
7
+ "From a pcode checkout run `make install`, or `uv tool install --editable '.[claude]'`."
8
+ )
9
+
10
+
11
+ class ClaudeHTTPError(ModelHTTPError):
12
+ """An API error the CLI reported for a `claude:` request."""
13
+
14
+
15
+ class ClaudeProcessError(ModelAPIError):
16
+ """The CLI process ended mid-request; a retry starts another (transient)."""
17
+
18
+
19
+ class ClaudeConnectionError(ModelAPIError):
20
+ """The CLI's request got no HTTP response: dropped, refused or timed out (transient).
21
+
22
+ Verified against the bundled CLI: all three arrive as a `server_error` with
23
+ no `api_error_status`, where a real 500 or 529 carries its status. With the
24
+ CLI's own retries off, pcode's transient retry covers these, as it covers
25
+ the same failures on its other providers.
26
+ """
27
+
28
+
29
+ class ClaudeStartError(ModelAPIError):
30
+ """The CLI process could not be started or resumed."""
31
+
32
+
33
+ class ClaudeSDKMissing(ValueError):
34
+ """pcode was installed without its `claude` extra."""
35
+
36
+
37
+ def failure_hint(error: BaseException) -> str | None:
38
+ """What to do about a failed `claude:` request, or None for any other provider."""
39
+ seen = set()
40
+ while error is not None and id(error) not in seen and len(seen) < 16:
41
+ seen.add(id(error))
42
+ if isinstance(error, ClaudeSDKMissing):
43
+ return MISSING_SDK
44
+ if isinstance(error, ClaudeConnectionError):
45
+ # The CLI's own text stays in the diagnostics log.
46
+ return (
47
+ "Claude Code got no response from Anthropic (connection dropped, refused "
48
+ "or timed out). Check network/proxy connectivity and retry when ready."
49
+ )
50
+ if isinstance(error, ClaudeHTTPError):
51
+ body = error.body if isinstance(error.body, dict) else {}
52
+ kind = (body.get("error") or {}).get("type")
53
+ if error.status_code in (401, 403) or kind == "authentication_failed":
54
+ return "Claude Code is not signed in, or its login expired. Run /login claude."
55
+ return None
56
+ if isinstance(error, ClaudeStartError):
57
+ return f"{error.message} Check the Claude Code install, or run /login claude."
58
+ if isinstance(error, ClaudeProcessError):
59
+ return f"{error.message} Retry; pcode starts a new Claude Code process."
60
+ error = error.__cause__ or error.__context__
61
+ return None
@@ -0,0 +1,154 @@
1
+ """History bookkeeping: normalizing, hashing and replaying Anthropic messages."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import io
6
+ import json
7
+ from typing import Any
8
+
9
+ REPLAY_INTRO = (
10
+ "This conversation began outside the current session, so its earlier messages are "
11
+ "replayed below as a transcript. Treat them as having happened here and continue "
12
+ "from the final message.\n\n"
13
+ )
14
+
15
+
16
+ def _clean(value: Any) -> Any:
17
+ """Drop cache markers and make binary sources plain base64 JSON.
18
+
19
+ The CLI places its own cache markers, and they never change content.
20
+ Pydantic AI maps image and PDF bytes to `io.BytesIO`, which the Anthropic
21
+ SDK encodes on send; the CLI takes JSON, and hashing needs stable values.
22
+ """
23
+ if isinstance(value, io.BytesIO):
24
+ return base64.b64encode(value.getvalue()).decode("ascii")
25
+ if isinstance(value, dict):
26
+ return {k: _clean(v) for k, v in value.items() if k != "cache_control"}
27
+ if isinstance(value, list):
28
+ return [_clean(v) for v in value]
29
+ return value
30
+
31
+
32
+ def _blocks(message: dict) -> list[dict]:
33
+ content = message.get("content")
34
+ if isinstance(content, str):
35
+ return [{"type": "text", "text": content}] if content else []
36
+ return list(content or [])
37
+
38
+
39
+ def normalize(messages: list[dict]) -> list[dict]:
40
+ """Anthropic messages without cache markers, consecutive same-role turns merged.
41
+
42
+ Pydantic AI leaves an appended reminder as its own user message; the API
43
+ merges consecutive turns anyway, and one user turn is one CLI input.
44
+ """
45
+ merged: list[dict] = []
46
+ for message in _clean(messages):
47
+ if merged and merged[-1]["role"] == message["role"]:
48
+ merged[-1] = {
49
+ "role": message["role"],
50
+ "content": _blocks(merged[-1]) + _blocks(message),
51
+ }
52
+ else:
53
+ merged.append({"role": message["role"], "content": _blocks(message)})
54
+ return merged
55
+
56
+
57
+ def _digest(previous: str, message: dict) -> str:
58
+ text = json.dumps(message, sort_keys=True, separators=(",", ":"), default=str)
59
+ return hashlib.sha256(f"{previous}\n{text}".encode()).hexdigest()
60
+
61
+
62
+ def lineage(messages: list[dict]) -> list[str]:
63
+ """One hash per message, each covering the whole history up to it."""
64
+ chain, previous = [], "pcode-claude-1"
65
+ for message in messages:
66
+ previous = _digest(previous, message)
67
+ chain.append(previous)
68
+ return chain
69
+
70
+
71
+ def _tool_use_ids(message: dict) -> tuple[str, ...]:
72
+ return tuple(b["id"] for b in _blocks(message) if b.get("type") == "tool_use")
73
+
74
+
75
+ def _result_ids(message: dict) -> set[str]:
76
+ return {b["tool_use_id"] for b in _blocks(message) if b.get("type") == "tool_result"}
77
+
78
+
79
+ def _result_text(block: dict) -> str:
80
+ content = block.get("content")
81
+ if isinstance(content, str):
82
+ return content
83
+ return "\n".join(_render_block(item) for item in content or [])
84
+
85
+
86
+ def _render_block(block: dict) -> str:
87
+ kind = block.get("type")
88
+ if kind == "text":
89
+ return block.get("text", "")
90
+ if kind == "tool_use":
91
+ arguments = json.dumps(block.get("input"), ensure_ascii=False, default=str)
92
+ return f"[Called tool {block.get('name')} (id {block.get('id')}) with {arguments}]"
93
+ if kind == "tool_result":
94
+ status = " (error)" if block.get("is_error") else ""
95
+ return f"[Result of tool call {block.get('tool_use_id')}{status}]\n{_result_text(block)}"
96
+ if kind in ("thinking", "redacted_thinking"):
97
+ return ""
98
+ return f"[{kind or 'content'} omitted]"
99
+
100
+
101
+ def replay(messages: list[dict], open_tool_ids: tuple[str, ...] = ()) -> list[dict]:
102
+ """One user turn carrying `messages`, for a CLI transcript that lacks them.
103
+
104
+ Results for the tool calls the transcript ends on (`open_tool_ids`) stay
105
+ structured, since the API requires them; everything else becomes text.
106
+ Images and documents from the final message are still attached.
107
+ """
108
+ first = _blocks(messages[0]) if messages else []
109
+ head = [
110
+ b for b in first if b.get("type") == "tool_result" and b["tool_use_id"] in open_tool_ids
111
+ ]
112
+ answered = {b["tool_use_id"] for b in head}
113
+ head += [
114
+ {
115
+ "type": "tool_result",
116
+ "tool_use_id": i,
117
+ "content": "[No result recorded]",
118
+ "is_error": True,
119
+ }
120
+ for i in open_tool_ids
121
+ if i not in answered
122
+ ]
123
+ rest = [
124
+ {"role": messages[0]["role"], "content": [b for b in first if b not in head]},
125
+ *messages[1:],
126
+ ]
127
+ turns = []
128
+ for message in rest:
129
+ text = "\n".join(filter(None, (_render_block(b) for b in _blocks(message))))
130
+ if text:
131
+ turns.append(f"[{message['role'].title()}]\n{text}")
132
+ attached = [b for b in _blocks(messages[-1]) if b.get("type") in ("image", "document")]
133
+ body = "\n\n".join(turns)
134
+ text = f"{REPLAY_INTRO}<conversation>\n{body}\n</conversation>" if turns else ""
135
+ return head + ([{"type": "text", "text": text}] if text else []) + attached
136
+
137
+
138
+ def _mcp_content(block: dict) -> list[dict]:
139
+ """An Anthropic tool_result's content as MCP content blocks."""
140
+ content = block.get("content")
141
+ if isinstance(content, str):
142
+ return [{"type": "text", "text": content}] if content else []
143
+ items = []
144
+ for item in content or []:
145
+ source = item.get("source") or {}
146
+ if item.get("type") == "text":
147
+ items.append({"type": "text", "text": item.get("text", "")})
148
+ elif item.get("type") == "image" and source.get("type") == "base64":
149
+ items.append(
150
+ {"type": "image", "data": source["data"], "mimeType": source["media_type"]}
151
+ )
152
+ else:
153
+ items.append({"type": "text", "text": _render_block(item)})
154
+ return items
@@ -0,0 +1,179 @@
1
+ """`ClaudeModel`: an `AnthropicModel` whose requests go through a CLI process."""
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from functools import cached_property
8
+ from typing import Any
9
+
10
+ from anthropic import AsyncAnthropic
11
+ from anthropic._models import construct_type
12
+ from anthropic.types.beta import BetaRawMessageStreamEvent
13
+ from pydantic_ai import RunContext
14
+ from pydantic_ai._utils import PeekableAsyncStream
15
+ from pydantic_ai.models import (
16
+ ModelRequestParameters,
17
+ StreamedResponse,
18
+ check_allow_model_requests,
19
+ )
20
+ from pydantic_ai.models.anthropic import AnthropicModel
21
+ from pydantic_ai.providers.anthropic import AnthropicProvider
22
+
23
+ from pcode.claude_sdk.errors import MISSING_SDK, ClaudeSDKMissing
24
+ from pcode.claude_sdk.messages import _digest, lineage, normalize
25
+ from pcode.claude_sdk.session import SessionConfig
26
+ from pcode.claude_sdk.session_pool import pool
27
+ from pcode.claude_sdk.workspace import CWD_SETTING
28
+
29
+ PREFIX = "claude:"
30
+ EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
31
+
32
+
33
+ class ClaudeProvider(AnthropicProvider):
34
+ """Names responses `claude`. Its client is never called: the CLI makes every request."""
35
+
36
+ @property
37
+ def name(self) -> str:
38
+ return "claude"
39
+
40
+ def __init__(self) -> None:
41
+ super().__init__(
42
+ anthropic_client=AsyncAnthropic(
43
+ api_key="unused", base_url="https://claude-code.invalid", max_retries=0
44
+ )
45
+ )
46
+
47
+
48
+ class _Events:
49
+ """Stream events as the Anthropic SDK types `_process_streamed_response` reads."""
50
+
51
+ def __init__(self, events: AsyncIterator[dict]) -> None:
52
+ self._events = events
53
+
54
+ def __aiter__(self):
55
+ return self
56
+
57
+ async def __anext__(self):
58
+ event = await self._events.__anext__()
59
+ return construct_type(type_=BetaRawMessageStreamEvent, value=event)
60
+
61
+ async def close(self) -> None:
62
+ await self._events.aclose()
63
+
64
+
65
+ # Model setting: False for a one-off request (a session title), whose process
66
+ # is stopped once it answers. Kept, it would be the newest idle process and
67
+ # evict the conversation's warm one under `claude_idle_processes`.
68
+ KEEP_WARM_SETTING = "pcode_claude_keep_warm"
69
+
70
+
71
+ class ClaudeModel(AnthropicModel):
72
+ @cached_property
73
+ def profile(self):
74
+ # The CLI forwards only MCP tools, so no Anthropic server tool reaches
75
+ # the model and web tools fall back to local ones. Like Meridian, wire
76
+ # tool deferral is off: hidden tools are withheld until found.
77
+ from pydantic_ai.profiles import merge_profile
78
+ from pydantic_ai.profiles.anthropic import AnthropicModelProfile
79
+
80
+ return merge_profile(
81
+ super().profile,
82
+ AnthropicModelProfile(
83
+ supported_native_tools=frozenset(),
84
+ tool_deferral_mode=None,
85
+ tool_addition_mode=None,
86
+ # The system prompt is fixed per process; mid-conversation
87
+ # system text must travel as user text instead.
88
+ supports_inline_system_prompts=False,
89
+ ),
90
+ )
91
+
92
+ def session_config(self, system, tools, settings) -> SessionConfig:
93
+ if isinstance(system, str):
94
+ prompt = system
95
+ else:
96
+ prompt = "\n\n".join(block["text"] for block in system if block.get("text"))
97
+ wire_tools = [
98
+ {
99
+ "name": tool["name"],
100
+ "description": tool.get("description") or "",
101
+ "input_schema": tool["input_schema"],
102
+ }
103
+ for tool in tools
104
+ if "input_schema" in tool
105
+ ]
106
+ effort = settings.get("anthropic_effort")
107
+ thinking = settings.get("anthropic_thinking")
108
+ max_tokens = settings.get("max_tokens")
109
+ return SessionConfig(
110
+ model=self.model_name,
111
+ cwd=str(settings.get(CWD_SETTING) or os.getcwd()),
112
+ system_prompt=prompt,
113
+ tools=json.dumps(wire_tools, sort_keys=True),
114
+ effort=effort if effort in EFFORTS else None,
115
+ thinking=json.dumps(thinking, sort_keys=True) if isinstance(thinking, dict) else None,
116
+ max_tokens=max_tokens if isinstance(max_tokens, int) and max_tokens > 0 else None,
117
+ )
118
+
119
+ async def request(self, messages, model_settings, model_request_parameters):
120
+ async with self.request_stream(
121
+ messages, model_settings, model_request_parameters
122
+ ) as stream:
123
+ async for _ in stream:
124
+ pass
125
+ return stream.get()
126
+
127
+ async def count_tokens(self, messages, model_settings, model_request_parameters):
128
+ raise NotImplementedError("claude: models cannot count tokens ahead of a request")
129
+
130
+ @asynccontextmanager
131
+ async def request_stream(
132
+ self,
133
+ messages,
134
+ model_settings,
135
+ model_request_parameters: ModelRequestParameters,
136
+ run_context: RunContext[Any] | None = None,
137
+ ) -> AsyncIterator[StreamedResponse]:
138
+ check_allow_model_requests()
139
+ settings, parameters = self.prepare_request(model_settings, model_request_parameters)
140
+ settings = dict(settings or {})
141
+ system, mapped = await self._map_message(messages, parameters, settings)
142
+ tools, _ = self._prepare_tools_and_tool_choice(settings, parameters)
143
+ config = self.session_config(system, tools, settings)
144
+ mapped = normalize(mapped)
145
+ chain = lineage(mapped)
146
+ sessions = pool()
147
+ checkout = await sessions.checkout(config, mapped, chain)
148
+ session = checkout.session
149
+ ok = False
150
+ try:
151
+ # `_process_streamed_response` peeks the first event before reading.
152
+ events = PeekableAsyncStream(_Events(session.response()))
153
+ stream = await self._process_streamed_response(events, parameters, settings)
154
+ yield stream
155
+ # Anything short of the whole message leaves the process mid-turn.
156
+ # A whole one is a fork point even if the process has since died.
157
+ if session.complete and session.stray_call:
158
+ # Resuming any of this history would show the model its own
159
+ # refused calls again: the next request replays it instead.
160
+ sessions.index.drop(chain)
161
+ elif session.complete and settings.get(KEEP_WARM_SETTING, True):
162
+ _, answer = await self._map_message([stream.get()], parameters, settings)
163
+ answer = normalize(answer)
164
+ if len(answer) == 1 and answer[0]["role"] == "assistant":
165
+ sessions.record(session, _digest(chain[-1], answer[0]))
166
+ ok = not session.dead
167
+ finally:
168
+ sessions.release(session, ok=ok)
169
+
170
+
171
+ def claude_model(model: str) -> ClaudeModel:
172
+ from pcode.models import claude_sdk_installed
173
+
174
+ name = model.removeprefix(PREFIX)
175
+ if not name.strip():
176
+ raise ValueError("Claude requires a model ID: claude:<model-id>")
177
+ if not claude_sdk_installed():
178
+ raise ClaudeSDKMissing(MISSING_SDK)
179
+ return ClaudeModel(name, provider=ClaudeProvider())
@@ -0,0 +1,114 @@
1
+ """The persisted index of fork points that lets a restarted pcode resume warm."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from tempfile import NamedTemporaryFile
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ INDEX_LIMIT = 4000
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ForkPoint:
17
+ """Where one assistant message pcode received lives in a CLI transcript."""
18
+
19
+ session_id: str
20
+ uuid: str
21
+ cwd: str
22
+
23
+
24
+ def index_path() -> Path:
25
+ state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
26
+ return state / "pcode" / "claude-sessions.jsonl"
27
+
28
+
29
+ class ResumeIndex:
30
+ """History hash -> fork point, persisted so a restarted pcode resumes warm.
31
+
32
+ Append-only lines shared by every pcode process; losing an entry only
33
+ costs a replay, so concurrent writers need no lock beyond `O_APPEND`.
34
+ """
35
+
36
+ def __init__(self, path: Path | None = None) -> None:
37
+ self.path = path
38
+ self._loaded: dict[str, ForkPoint] | None = None
39
+
40
+ def _file(self) -> Path:
41
+ return self.path or index_path()
42
+
43
+ @property
44
+ def _entries(self) -> dict[str, ForkPoint]:
45
+ # Read once per process, synchronously: at most INDEX_LIMIT short lines,
46
+ # and no await in between means no second reader can interleave.
47
+ if self._loaded is None:
48
+ self._loaded = self._load()
49
+ return self._loaded
50
+
51
+ def _load(self) -> dict[str, ForkPoint]:
52
+ entries: dict[str, ForkPoint] = {}
53
+ try:
54
+ lines = self._file().read_text().splitlines()
55
+ except OSError:
56
+ return entries
57
+ for line in lines:
58
+ try:
59
+ row = json.loads(line)
60
+ if row.get("dropped"):
61
+ entries.pop(row["key"], None)
62
+ continue
63
+ entries[row["key"]] = ForkPoint(row["session"], row["uuid"], row["cwd"])
64
+ except (ValueError, KeyError, TypeError, AttributeError):
65
+ continue
66
+ if len(lines) > INDEX_LIMIT:
67
+ kept = list(entries.items())[-INDEX_LIMIT // 2 :]
68
+ entries = dict(kept)
69
+ temporary = None
70
+ try:
71
+ with NamedTemporaryFile(
72
+ "w", dir=self._file().parent, suffix=".tmp", delete=False
73
+ ) as file:
74
+ temporary = Path(file.name)
75
+ file.write("".join(self._line(k, p) for k, p in kept))
76
+ temporary.replace(self._file())
77
+ except OSError:
78
+ if temporary is not None:
79
+ temporary.unlink(missing_ok=True)
80
+ return entries
81
+
82
+ @staticmethod
83
+ def _line(key: str, point: ForkPoint) -> str:
84
+ row = {"key": key, "session": point.session_id, "uuid": point.uuid, "cwd": point.cwd}
85
+ return json.dumps(row) + "\n"
86
+
87
+ def get(self, key: str) -> ForkPoint | None:
88
+ return self._entries.get(key)
89
+
90
+ def forget(self, session_id: str) -> None:
91
+ """Stop offering a transcript that could not be resumed, for this process."""
92
+ for key in [k for k, p in self._entries.items() if p.session_id == session_id]:
93
+ del self._entries[key]
94
+
95
+ def drop(self, keys: list[str]) -> None:
96
+ """Never fork these histories again, in any pcode process."""
97
+ dropped = [key for key in keys if self._entries.pop(key, None) is not None]
98
+ self._append("".join(json.dumps({"key": key, "dropped": True}) + "\n" for key in dropped))
99
+
100
+ def add(self, key: str, point: ForkPoint) -> None:
101
+ if self._entries.get(key) == point:
102
+ return
103
+ self._entries[key] = point
104
+ self._append(self._line(key, point))
105
+
106
+ def _append(self, lines: str) -> None:
107
+ if not lines:
108
+ return
109
+ try:
110
+ self._file().parent.mkdir(parents=True, exist_ok=True)
111
+ with self._file().open("a") as file:
112
+ file.write(lines)
113
+ except OSError:
114
+ logger.debug("could not update the Claude fork index", exc_info=True)