pcode 0.1.3__tar.gz → 0.1.4__tar.gz

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 (171) hide show
  1. {pcode-0.1.3 → pcode-0.1.4}/PKG-INFO +1 -1
  2. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/claude_sdk/messages.py +22 -0
  3. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/claude_sdk/model.py +10 -6
  4. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/claude_sdk/session.py +135 -9
  5. pcode-0.1.4/src/pcode/claude_sdk/web.py +85 -0
  6. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/config_ui.py +170 -75
  7. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/ext.py +5 -3
  8. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/extension_guide.md +3 -0
  9. pcode-0.1.4/src/pcode/extensions/plan_mode.py +152 -0
  10. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/extensions/web_research.py +11 -7
  11. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/preferences.py +7 -0
  12. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/tool_display.py +5 -4
  13. {pcode-0.1.3 → pcode-0.1.4}/.gitignore +0 -0
  14. {pcode-0.1.3 → pcode-0.1.4}/LICENSE +0 -0
  15. {pcode-0.1.3 → pcode-0.1.4}/README.md +0 -0
  16. {pcode-0.1.3 → pcode-0.1.4}/pyproject.toml +0 -0
  17. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/__init__.py +0 -0
  18. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/_job_supervisor.py +0 -0
  19. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/agent.py +0 -0
  20. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/anthropic_oauth.py +0 -0
  21. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/app.py +0 -0
  22. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/appearance.py +0 -0
  23. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/aside.py +0 -0
  24. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/aside_guard.py +0 -0
  25. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/aside_ui.py +0 -0
  26. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/auth.py +0 -0
  27. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/background_delegation.py +0 -0
  28. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/block.py +0 -0
  29. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/browser.py +0 -0
  30. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/cache_diagnostics.py +0 -0
  31. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/cache_settings.py +0 -0
  32. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/cache_warnings.py +0 -0
  33. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/claude_sdk/__init__.py +0 -0
  34. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/claude_sdk/cli.py +0 -0
  35. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/claude_sdk/errors.py +0 -0
  36. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/claude_sdk/resume.py +0 -0
  37. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/claude_sdk/session_pool.py +0 -0
  38. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/claude_sdk/workspace.py +0 -0
  39. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/cli.py +0 -0
  40. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/clipboard.py +0 -0
  41. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/code_mode.py +0 -0
  42. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/codex_login.py +0 -0
  43. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/command_transcript.py +0 -0
  44. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/commands.py +0 -0
  45. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/compaction.py +0 -0
  46. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/completion.py +0 -0
  47. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/config.py +0 -0
  48. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/context_breakdown.py +0 -0
  49. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/context_usage.py +0 -0
  50. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/controller.py +0 -0
  51. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/conversation_ids.py +0 -0
  52. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/conversation_tree.py +0 -0
  53. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/copy_ui.py +0 -0
  54. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/delegation.py +0 -0
  55. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/delta.py +0 -0
  56. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/diagnostics.py +0 -0
  57. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/edit_preview.py +0 -0
  58. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/edit_transcript.py +0 -0
  59. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/edit_ui.py +0 -0
  60. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/edits.py +0 -0
  61. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/email_remote/__init__.py +0 -0
  62. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/email_remote/cli.py +0 -0
  63. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/email_remote/listener.py +0 -0
  64. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/email_remote/mailbox.py +0 -0
  65. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/email_remote/outbound.py +0 -0
  66. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/email_remote/parsing.py +0 -0
  67. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/email_remote/render.py +0 -0
  68. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/email_remote/state.py +0 -0
  69. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/error_report.py +0 -0
  70. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/extensions/browser.py +0 -0
  71. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/extensions/ponytail.py +0 -0
  72. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/extensions/sandbox.py +0 -0
  73. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/extensions/session_history.py +0 -0
  74. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/file_refs.py +0 -0
  75. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/filesystem.py +0 -0
  76. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/frame.py +0 -0
  77. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/gateway.py +0 -0
  78. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/git_diff.py +0 -0
  79. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/history.py +0 -0
  80. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/history_cursor.py +0 -0
  81. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/history_embeddings.py +0 -0
  82. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/host.py +0 -0
  83. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/host_protocol.py +0 -0
  84. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/host_ui.py +0 -0
  85. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/input_keys.py +0 -0
  86. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/inspection.py +0 -0
  87. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/inspector_ui.py +0 -0
  88. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/isolated_delegation.py +0 -0
  89. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/job_notices.py +0 -0
  90. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/jobs.py +0 -0
  91. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/jobs_ui.py +0 -0
  92. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/keyboard_protocol.py +0 -0
  93. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/keymap.py +0 -0
  94. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/layout_speed.py +0 -0
  95. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/links.py +0 -0
  96. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/links_ui.py +0 -0
  97. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/live.py +0 -0
  98. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/llm_proxy.py +0 -0
  99. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/logins.py +0 -0
  100. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/mcp.py +0 -0
  101. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/mcp_notice.py +0 -0
  102. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/mcp_oauth.py +0 -0
  103. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/meridian.py +0 -0
  104. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/meridian_process.py +0 -0
  105. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/meridian_reminders.py +0 -0
  106. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/meridian_setup.py +0 -0
  107. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/model_metadata.py +0 -0
  108. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/model_ui.py +0 -0
  109. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/models.py +0 -0
  110. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/native_results.py +0 -0
  111. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/oauth_pages.py +0 -0
  112. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/output_limits.py +0 -0
  113. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/paste.py +0 -0
  114. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/path_completion.py +0 -0
  115. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/plan_preview.py +0 -0
  116. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/plan_sizes.py +0 -0
  117. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/planning.py +0 -0
  118. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/popup_ui.py +0 -0
  119. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/prefix_keys.py +0 -0
  120. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/proctitle.py +0 -0
  121. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/profile_benchmark.py +0 -0
  122. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/profiling.py +0 -0
  123. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/project_trust.py +0 -0
  124. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/prompt_keys.py +0 -0
  125. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/remote.py +0 -0
  126. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/remote_print.py +0 -0
  127. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/remote_profile.py +0 -0
  128. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/replay_benchmark.py +0 -0
  129. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/repo_context.py +0 -0
  130. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/retries.py +0 -0
  131. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/rpc.py +0 -0
  132. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/runtime.py +0 -0
  133. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/sandbox.py +0 -0
  134. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/session_naming.py +0 -0
  135. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/session_ui.py +0 -0
  136. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/sessions.py +0 -0
  137. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/shell.py +0 -0
  138. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/shell_mode.py +0 -0
  139. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/shell_tools.py +0 -0
  140. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/skills.py +0 -0
  141. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/stall_log.py +0 -0
  142. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/startup_input.py +0 -0
  143. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/steering.py +0 -0
  144. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/stream_display.py +0 -0
  145. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/stream_events.py +0 -0
  146. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/strict_tools.py +0 -0
  147. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/syntax.py +0 -0
  148. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/syntax_colors.py +0 -0
  149. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/task_prompt.py +0 -0
  150. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/task_worktrees.py +0 -0
  151. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/terminal_notify.py +0 -0
  152. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/terminal_text.py +0 -0
  153. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/theme.py +0 -0
  154. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/theme_gallery.py +0 -0
  155. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/thinking_markdown.py +0 -0
  156. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/token_accounting.py +0 -0
  157. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/tool_output_limits.py +0 -0
  158. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/tool_panel.py +0 -0
  159. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/transcript_log.py +0 -0
  160. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/transcript_notice.py +0 -0
  161. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/tree_ui.py +0 -0
  162. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/turn.py +0 -0
  163. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/ui.py +0 -0
  164. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/update_check.py +0 -0
  165. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/usage.py +0 -0
  166. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/word_wrap.py +0 -0
  167. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/worker_ui.py +0 -0
  168. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/workers.py +0 -0
  169. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/workspace.py +0 -0
  170. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/workspace_filesystem.py +0 -0
  171. {pcode-0.1.3 → pcode-0.1.4}/src/pcode/worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pcode
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Terminal-native AI coding agent with background jobs, parallel worktrees, forkable sessions, and multi-model support
5
5
  Project-URL: Homepage, https://github.com/cruxwell/pcode
6
6
  Project-URL: Documentation, https://cruxwell.github.io/pcode/
@@ -95,9 +95,31 @@ def _render_block(block: dict) -> str:
95
95
  return f"[Result of tool call {block.get('tool_use_id')}{status}]\n{_result_text(block)}"
96
96
  if kind in ("thinking", "redacted_thinking"):
97
97
  return ""
98
+ if kind == "server_tool_use":
99
+ arguments = json.dumps(block.get("input"), ensure_ascii=False, default=str)
100
+ return f"[Ran {block.get('name')} (id {block.get('id')}) with {arguments}]"
101
+ if kind in ("web_search_tool_result", "web_fetch_tool_result"):
102
+ return f"[Result of {block.get('tool_use_id')}]\n{_web_result_text(block.get('content'))}"
98
103
  return f"[{kind or 'content'} omitted]"
99
104
 
100
105
 
106
+ def _web_result_text(content: Any) -> str:
107
+ """A web search's hits or a fetched page, as `web.result_block` shaped them."""
108
+ if isinstance(content, list):
109
+ return "\n".join(
110
+ f"{item.get('title') or item.get('url')}: {item.get('url')}"
111
+ for item in content
112
+ if isinstance(item, dict)
113
+ )
114
+ if not isinstance(content, dict):
115
+ return ""
116
+ if "error_code" in content:
117
+ return f"(error: {content['error_code']})"
118
+ document = content.get("content")
119
+ source = document.get("source") if isinstance(document, dict) else None
120
+ return str(source.get("data", "")) if isinstance(source, dict) else ""
121
+
122
+
101
123
  def replay(messages: list[dict], open_tool_ids: tuple[str, ...] = ()) -> list[dict]:
102
124
  """One user turn carrying `messages`, for a CLI transcript that lacks them.
103
125
 
@@ -24,6 +24,7 @@ from pcode.claude_sdk.errors import MISSING_SDK, ClaudeSDKMissing
24
24
  from pcode.claude_sdk.messages import _digest, lineage, normalize
25
25
  from pcode.claude_sdk.session import SessionConfig
26
26
  from pcode.claude_sdk.session_pool import pool
27
+ from pcode.claude_sdk.web import cli_tool_names
27
28
  from pcode.claude_sdk.workspace import CWD_SETTING
28
29
 
29
30
  PREFIX = "claude:"
@@ -71,16 +72,18 @@ KEEP_WARM_SETTING = "pcode_claude_keep_warm"
71
72
  class ClaudeModel(AnthropicModel):
72
73
  @cached_property
73
74
  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.
75
+ # No Anthropic server tool reaches the model through the CLI, but the
76
+ # CLI's own WebSearch and WebFetch stand in for those two (`web.py`).
77
+ # Like Meridian, wire tool deferral is off: hidden tools are withheld
78
+ # until found.
79
+ from pydantic_ai.native_tools import WebFetchTool, WebSearchTool
77
80
  from pydantic_ai.profiles import merge_profile
78
81
  from pydantic_ai.profiles.anthropic import AnthropicModelProfile
79
82
 
80
83
  return merge_profile(
81
84
  super().profile,
82
85
  AnthropicModelProfile(
83
- supported_native_tools=frozenset(),
86
+ supported_native_tools=frozenset({WebSearchTool, WebFetchTool}),
84
87
  tool_deferral_mode=None,
85
88
  tool_addition_mode=None,
86
89
  # The system prompt is fixed per process; mid-conversation
@@ -89,7 +92,7 @@ class ClaudeModel(AnthropicModel):
89
92
  ),
90
93
  )
91
94
 
92
- def session_config(self, system, tools, settings) -> SessionConfig:
95
+ def session_config(self, system, tools, settings, native_tools=()) -> SessionConfig:
93
96
  if isinstance(system, str):
94
97
  prompt = system
95
98
  else:
@@ -114,6 +117,7 @@ class ClaudeModel(AnthropicModel):
114
117
  effort=effort if effort in EFFORTS else None,
115
118
  thinking=json.dumps(thinking, sort_keys=True) if isinstance(thinking, dict) else None,
116
119
  max_tokens=max_tokens if isinstance(max_tokens, int) and max_tokens > 0 else None,
120
+ web_tools=cli_tool_names(native_tools),
117
121
  )
118
122
 
119
123
  async def request(self, messages, model_settings, model_request_parameters):
@@ -140,7 +144,7 @@ class ClaudeModel(AnthropicModel):
140
144
  settings = dict(settings or {})
141
145
  system, mapped = await self._map_message(messages, parameters, settings)
142
146
  tools, _ = self._prepare_tools_and_tool_choice(settings, parameters)
143
- config = self.session_config(system, tools, settings)
147
+ config = self.session_config(system, tools, settings, parameters.native_tools)
144
148
  mapped = normalize(mapped)
145
149
  chain = lineage(mapped)
146
150
  sessions = pool()
@@ -23,6 +23,7 @@ from pcode.claude_sdk.errors import (
23
23
  )
24
24
  from pcode.claude_sdk.messages import _blocks, _mcp_content, _result_ids
25
25
  from pcode.claude_sdk.resume import ForkPoint
26
+ from pcode.claude_sdk.web import result_block, server_tool_use
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
@@ -41,6 +42,9 @@ CALL_TIMEOUT_SECONDS = 10.0
41
42
  # that gap goes out with none (verified with 2.1.285), so wait for them.
42
43
  TOOLS_READY_TIMEOUT_SECONDS = 30.0
43
44
  TOOLS_READY_POLL_SECONDS = 0.02
45
+ # The CLI can run its web tools before a pcode call made beside them, so that call
46
+ # reaches pcode only once they finish. The CLI bounds each fetch and search itself.
47
+ WEB_TOOLS_TIMEOUT_SECONDS = 300.0
44
48
  # Statuses for the CLI's error kinds when it reports no HTTP status itself.
45
49
  # `server_error` has none on purpose: without a status it is a connection failure
46
50
  # (`ClaudeConnectionError`), while a real 5xx or 529 always carries its status.
@@ -63,6 +67,8 @@ class SessionConfig:
63
67
  thinking: str | None = None # canonical JSON of the thinking setting
64
68
  # pcode's output ceiling; the CLI clamps it to the model's own limit.
65
69
  max_tokens: int | None = None
70
+ # The CLI's own web tools to offer (`web.CLI_WEB_TOOLS`), which it runs itself.
71
+ web_tools: tuple[str, ...] = ()
66
72
 
67
73
  def options(self, server, resume: ForkPoint | None, stderr, prompt_file: str) -> Any:
68
74
  from claude_agent_sdk import ClaudeAgentOptions
@@ -71,13 +77,14 @@ class SessionConfig:
71
77
  if thinking is not None and thinking.get("type") != "disabled":
72
78
  thinking = {"display": "summarized", **thinking}
73
79
  return ClaudeAgentOptions(
74
- # No built-in tools, settings, CLAUDE.md, plugins or other MCP
75
- # servers: the model sees pcode's prompt and pcode's tools only.
76
- tools=[],
80
+ # No settings, CLAUDE.md, plugins, other MCP servers or built-in
81
+ # tools beyond the web ones pcode asked for: the model sees pcode's
82
+ # prompt and pcode's tools only.
83
+ tools=list(self.web_tools),
77
84
  setting_sources=[],
78
85
  strict_mcp_config=True,
79
86
  mcp_servers={SERVER: {"type": "sdk", "name": SERVER, "instance": server}},
80
- allowed_tools=[f"mcp__{SERVER}"],
87
+ allowed_tools=[f"mcp__{SERVER}", *self.web_tools],
81
88
  system_prompt={"type": "file", "path": prompt_file},
82
89
  include_partial_messages=True,
83
90
  model=self.model,
@@ -107,6 +114,29 @@ def _client_factory(options) -> Any:
107
114
  return ClaudeSDKClient(options)
108
115
 
109
116
 
117
+ def _block_events(block: dict, index: int) -> list[dict]:
118
+ """A whole content block as the start and stop events that carry it."""
119
+ return [
120
+ {"type": "content_block_start", "index": index, "content_block": block},
121
+ {"type": "content_block_stop", "index": index},
122
+ ]
123
+
124
+
125
+ def _sum(first: dict, second: dict) -> dict:
126
+ """Two messages' usage as one: counts add up, anything else is the latest."""
127
+ total = dict(first)
128
+ for key, value in second.items():
129
+ if isinstance(value, dict):
130
+ earlier = total.get(key)
131
+ total[key] = _sum(earlier if isinstance(earlier, dict) else {}, value)
132
+ elif isinstance(value, int) and not isinstance(value, bool):
133
+ earlier = total.get(key)
134
+ total[key] = value + (earlier if isinstance(earlier, int) else 0)
135
+ else:
136
+ total[key] = value
137
+ return total
138
+
139
+
110
140
  class _Diverged(ClaudeProcessError):
111
141
  """The CLI's transcript stopped matching pcode's history (it acted on its own)."""
112
142
 
@@ -145,6 +175,10 @@ class ClaudeSession:
145
175
  # a released handler, or sent as input). Any other result is its own.
146
176
  self._called: set[str] = set()
147
177
  self._answered: set[str] = set()
178
+ # Web tool calls the CLI is running itself (id -> name, input), until
179
+ # their results arrive; and the tool names in the message streaming now.
180
+ self._web_calls: dict[str, tuple[str, dict]] = {}
181
+ self._message_tools: list[str] = []
148
182
  # Pulsed on every change a waiter may be waiting for.
149
183
  self._changed = asyncio.Event()
150
184
  self._last_uuid: str | None = None
@@ -372,6 +406,8 @@ class ClaudeSession:
372
406
  raise _Diverged(self.config.model, "Claude Code did not end its turn.") from None
373
407
  self._ask()
374
408
  self._turn_done.clear()
409
+ # The last turn ended, so any web call still listed will never answer.
410
+ self._web_calls.clear()
375
411
  # A fork may start with results for the calls its transcript ends on.
376
412
  self._answered.update(b["tool_use_id"] for b in content if b.get("type") == "tool_result")
377
413
  await self._write(content)
@@ -384,9 +420,17 @@ class ClaudeSession:
384
420
  Input written before the results is already queued when they arrive, so
385
421
  the CLI sends both in one request (verified with 2.1.283).
386
422
  """
423
+
424
+ def reached() -> bool:
425
+ return bool(self._called & set(self.open_tool_ids)) or self.dead
426
+
387
427
  try:
428
+ # The CLI may run its web tools first; until they finish, a call
429
+ # not reaching pcode yet is no sign of a refusal.
430
+ async with asyncio.timeout(WEB_TOOLS_TIMEOUT_SECONDS):
431
+ await self._until(lambda: reached() or not self._web_calls)
388
432
  async with asyncio.timeout(CALL_TIMEOUT_SECONDS):
389
- await self._until(lambda: bool(self._called & set(self.open_tool_ids)) or self.dead)
433
+ await self._until(reached)
390
434
  except TimeoutError:
391
435
  self._diverge("no tool call reached pcode")
392
436
  self._ask()
@@ -431,20 +475,36 @@ class ClaudeSession:
431
475
  kind = event.get("type")
432
476
  if kind == "message_start":
433
477
  stop_reason = None
478
+ self._message_tools = []
434
479
  wanted, self._expecting = self._expecting, False
435
480
  if not wanted:
436
481
  # A request of its own (a nudge after a thinking-only
437
482
  # reply, output-limit recovery, a retried tool call).
438
483
  self._diverge("a message pcode did not ask for")
484
+ elif kind == "content_block_start":
485
+ block = event.get("content_block") or {}
486
+ if block.get("type") == "tool_use":
487
+ name = str(block.get("name", ""))
488
+ self._message_tools.append(name)
489
+ if name in self.config.web_tools:
490
+ self._web_calls[block.get("id")] = (name, {})
439
491
  elif kind == "message_delta":
440
492
  stop_reason = (event.get("delta") or {}).get("stop_reason") or stop_reason
441
493
  elif kind == "message_stop" and stop_reason != "tool_use":
442
494
  # The turn ends here; absorb its result message.
443
495
  self._final = True
496
+ elif kind == "message_stop" and wanted and self._web_only():
497
+ # The CLI runs its web tools and asks again: that next
498
+ # message continues the response pcode asked for.
499
+ self._expecting = True
444
500
  if wanted:
445
501
  self._queue.put_nowait(("event", event, self._last_uuid))
446
502
  elif isinstance(message, AssistantMessage):
447
503
  self._last_uuid = message.uuid or self._last_uuid
504
+ for block in message.content:
505
+ call = getattr(block, "id", None)
506
+ if call in self._web_calls:
507
+ self._web_calls[call] = (block.name, dict(block.input or {}))
448
508
  if message.error:
449
509
  text = " ".join(getattr(b, "text", "") for b in message.content)
450
510
  self._api_error = (message.error, text.strip())
@@ -486,14 +546,41 @@ class ClaudeSession:
486
546
  content = message.content if isinstance(message.content, list) else []
487
547
  for block in content:
488
548
  tool_use_id = getattr(block, "tool_use_id", None)
489
- if tool_use_id and tool_use_id not in self._answered and not self._closed:
549
+ if tool_use_id in self._web_calls:
550
+ # One of the CLI's own web calls: its result is part of the response.
551
+ name, args = self._web_calls.pop(tool_use_id)
552
+ result = result_block(name, tool_use_id, args, block.content, bool(block.is_error))
553
+ self._queue.put_nowait(("web_result", result, None))
554
+ self._changed.set()
555
+ elif tool_use_id and tool_use_id not in self._answered and not self._closed:
490
556
  self._diverge(f"Claude Code answered tool call {tool_use_id} itself")
491
557
 
558
+ def _web_only(self) -> bool:
559
+ """Whether the message just streamed called the CLI's web tools and nothing else."""
560
+ return bool(self._message_tools) and all(
561
+ name in self.config.web_tools for name in self._message_tools
562
+ )
563
+
492
564
  async def response(self) -> AsyncIterator[dict]:
493
- """Raw stream events of the CLI's next assistant message, through message_stop."""
565
+ """Raw stream events of the CLI's next assistant message, through message_stop.
566
+
567
+ A message whose only calls are the CLI's web tools is not the end: the
568
+ CLI runs them and sends another, which is folded into this one as an
569
+ Anthropic server tool loop would be. Block indices run on across the
570
+ messages, the web calls become server tool blocks with their results
571
+ inserted after them, and usage is the sum of every message folded in.
572
+ Results of web calls made beside a pcode call arrive after that
573
+ response ended, so they open the next one.
574
+ """
494
575
  tool_ids: list[str] = []
576
+ web_ids: list[str] = []
495
577
  stop_reason = None
496
578
  self.stray_call = False
579
+ started = False
580
+ early: list[dict] = [] # web results that came before this message did
581
+ shift = emitted = 0 # added to the current message's indices; blocks so far
582
+ folded: dict = {} # usage of the messages already folded in
583
+ usage: dict = {} # usage of the current message, as it accumulates
497
584
  while True:
498
585
  kind, value, uuid = await self._queue.get()
499
586
  if kind == "error":
@@ -502,18 +589,57 @@ class ClaudeSession:
502
589
  if kind == "result":
503
590
  self.dead = True
504
591
  raise self._result_error(value)
592
+ if kind == "web_result":
593
+ if not started:
594
+ early.append(value)
595
+ continue
596
+ for event in _block_events(value, emitted):
597
+ yield event
598
+ emitted += 1
599
+ continue
505
600
  event_type = value.get("type")
601
+ if event_type == "message_start":
602
+ tool_ids, web_ids, stop_reason = [], [], None
603
+ usage = dict((value.get("message") or {}).get("usage") or {})
604
+ if started:
605
+ # A continuation: its usage joins the response's, the rest is known.
606
+ yield {"type": "message_delta", "delta": {}, "usage": _sum(folded, usage)}
607
+ else:
608
+ started = True
609
+ yield value
610
+ for block in early:
611
+ for event in _block_events(block, emitted):
612
+ yield event
613
+ emitted += 1
614
+ shift = emitted
615
+ continue
616
+ if isinstance(value.get("index"), int):
617
+ value = {**value, "index": value["index"] + shift}
618
+ emitted = max(emitted, value["index"] + 1)
506
619
  if event_type == "content_block_start":
507
620
  block = value.get("content_block") or {}
508
- if block.get("type") == "tool_use":
621
+ wire = str(block.get("name", ""))
622
+ if block.get("type") == "tool_use" and wire in self.config.web_tools:
623
+ web_ids.append(block.get("id"))
624
+ value = {**value, "content_block": server_tool_use(block)}
625
+ elif block.get("type") == "tool_use":
509
626
  tool_ids.append(block.get("id"))
510
- wire = str(block.get("name", ""))
511
627
  self.stray_call = self.stray_call or not wire.startswith(TOOL_PREFIX)
512
628
  name = wire.removeprefix(TOOL_PREFIX)
513
629
  value = {**value, "content_block": {**block, "name": name}}
514
630
  elif event_type == "message_delta":
515
631
  stop_reason = (value.get("delta") or {}).get("stop_reason") or stop_reason
632
+ usage.update(value.get("usage") or {})
633
+ folding = stop_reason == "tool_use" and web_ids and not tool_ids
634
+ if folding or folded:
635
+ delta = dict(value.get("delta") or {})
636
+ if folding:
637
+ delta.pop("stop_reason", None) # the response goes on
638
+ value = {**value, "delta": delta, "usage": _sum(folded, usage)}
516
639
  elif event_type == "message_stop":
640
+ if stop_reason == "tool_use" and web_ids and not tool_ids:
641
+ folded = _sum(folded, usage)
642
+ continue
517
643
  # Settled before handing it over, in case the reader stops here.
518
644
  self.open_tool_ids = tuple(tool_ids) if stop_reason == "tool_use" else ()
519
645
  self.response_uuid = uuid
@@ -0,0 +1,85 @@
1
+ """The CLI's own `WebSearch`/`WebFetch`, presented as Anthropic server tools.
2
+
3
+ The CLI runs both itself, between two API messages of one turn. pcode shows
4
+ them the way it shows Anthropic's native web tools: a `server_tool_use` block
5
+ and a result block, built here in the shapes Pydantic AI parses.
6
+
7
+ The blocks are pcode's record only; the model reads the CLI's own transcript.
8
+ They are tagged with the `claude` provider, so no other model is ever sent
9
+ them, and they are used just for display and for a fresh replay
10
+ (`messages.replay`).
11
+ """
12
+
13
+ import json
14
+ import re
15
+
16
+ # CLI tool name -> Anthropic server tool name (also the native tool's `kind`).
17
+ CLI_WEB_TOOLS = {"WebSearch": "web_search", "WebFetch": "web_fetch"}
18
+
19
+ # The CLI's search result opens with its query, then this JSON list of hits,
20
+ # then a summary its own sub-request wrote.
21
+ _LINKS = re.compile(r"^Links: (\[.*\])$", re.MULTILINE)
22
+
23
+
24
+ def cli_tool_names(native_tools) -> tuple[str, ...]:
25
+ """The CLI tools that stand in for the native tools a request asks for."""
26
+ wanted = {tool.kind for tool in native_tools}
27
+ return tuple(sorted(cli for cli, server in CLI_WEB_TOOLS.items() if server in wanted))
28
+
29
+
30
+ def server_tool_use(block: dict) -> dict:
31
+ """A CLI web `tool_use` block as the `server_tool_use` block it stands for."""
32
+ return {**block, "type": "server_tool_use", "name": CLI_WEB_TOOLS[block["name"]]}
33
+
34
+
35
+ def _text(content: object) -> str:
36
+ if isinstance(content, str):
37
+ return content
38
+ if isinstance(content, list):
39
+ return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
40
+ return ""
41
+
42
+
43
+ def _hits(text: str) -> list[dict]:
44
+ match = _LINKS.search(text)
45
+ try:
46
+ links = json.loads(match.group(1)) if match else []
47
+ except ValueError:
48
+ links = []
49
+ return [
50
+ {
51
+ "type": "web_search_result",
52
+ "title": str(link.get("title") or ""),
53
+ "url": str(link["url"]),
54
+ # Pydantic AI's result model requires both; the CLI has neither.
55
+ "encrypted_content": "",
56
+ "page_age": None,
57
+ }
58
+ for link in links
59
+ if isinstance(link, dict) and link.get("url")
60
+ ]
61
+
62
+
63
+ def result_block(name: str, tool_use_id: str, args: dict, content: object, is_error: bool) -> dict:
64
+ """The CLI's result for a web tool call as the server tool's result block."""
65
+ server = CLI_WEB_TOOLS[name]
66
+ text = _text(content)
67
+ if is_error:
68
+ # Anthropic's error block holds a code only; the CLI's message is more
69
+ # use to whoever reads the failure, so it travels in that field.
70
+ first = text.strip().splitlines()[0] if text.strip() else "unavailable"
71
+ body: object = {"type": f"{server}_tool_result_error", "error_code": first[:200]}
72
+ elif server == "web_search":
73
+ body = _hits(text)
74
+ else:
75
+ body = {
76
+ "type": "web_fetch_result",
77
+ "url": str(args.get("url") or ""),
78
+ "retrieved_at": None,
79
+ "content": {
80
+ "type": "document",
81
+ "source": {"type": "text", "media_type": "text/plain", "data": text},
82
+ "title": None,
83
+ },
84
+ }
85
+ return {"type": f"{server}_tool_result", "tool_use_id": tool_use_id, "content": body}