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/tool_display.py ADDED
@@ -0,0 +1,784 @@
1
+ """Tool presentation with visible inputs and sanitized command failure excerpts."""
2
+
3
+ import ast
4
+ import json
5
+ import re
6
+ from collections import Counter
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from rich.text import Text
11
+
12
+ from pcode.code_mode import SANDBOXED_TOOLS
13
+ from pcode.diagnostics import redact
14
+
15
+ if TYPE_CHECKING:
16
+ from pcode.jobs import JobRegistry
17
+
18
+ LABELS = {
19
+ # Verb + object, so a group tally ("Read file ✓ 15 · Run shell ✓ 1") says
20
+ # what each count acted on without the per-row path or command beside it.
21
+ "run_code": "Run code",
22
+ "delegate_task": "Delegate task",
23
+ "read_file": "Read file",
24
+ "read_tool_result": "Read results",
25
+ "write_file": "Write file",
26
+ "edit_file": "Edit file",
27
+ "search_files": "Search code",
28
+ "find_files": "Find files",
29
+ "list_directory": "List directory",
30
+ "create_directory": "Create directory",
31
+ "file_info": "File info",
32
+ "shell": "Run shell",
33
+ "wait_for_job": "Wait for job",
34
+ "job_output": "Read job output",
35
+ "stop_job": "Stop job",
36
+ "list_files": "Find files",
37
+ "grep": "Search code",
38
+ "run_command": "Run shell",
39
+ "start_command": "Start command",
40
+ "check_command": "Check command",
41
+ "stop_command": "Stop command",
42
+ "write_plan": "Write plan",
43
+ "search_tools": "Find tools",
44
+ "search_sessions": "Search sessions",
45
+ "read_session": "Read session",
46
+ "inventory_agent_context": "Context",
47
+ }
48
+ _SENSITIVE = re.compile(
49
+ r"(?i)(secret|token|password|credential|api.?key|private.?key|\.env|kubeconfig|\.tfvars)"
50
+ )
51
+
52
+
53
+ def plain(value: str, limit: int | None = 90) -> str:
54
+ """Remove controls (including bidi/ANSI introducers) and bound terminal text."""
55
+ text = "".join(c if c.isprintable() else " " for c in value)
56
+ return text if limit is None or len(text) <= limit else text[: limit - 1] + "…"
57
+
58
+
59
+ def label(name: str) -> str:
60
+ return LABELS.get(name, plain(name))
61
+
62
+
63
+ _CREDENTIAL_OPTION = re.compile(
64
+ r"(?i)(--?(?:password|passwd|token|access[-_]token|refresh[-_]token|"
65
+ r"api[-_]key|secret|client[-_]secret|authorization)(?:=|\s+))"
66
+ r"(\"[^\"]*\"|'[^']*'|[^\s;|&]+)"
67
+ )
68
+
69
+
70
+ def argument(value: str, *, command: bool = False) -> str:
71
+ if command:
72
+ value = _CREDENTIAL_OPTION.sub(r"\1[redacted]", value)
73
+ # Redact before formatting, so neither wrapping nor sanitization splits credentials.
74
+ return plain(redact(value).replace("\n", r"\n").replace("\t", r"\t"), limit=None)
75
+
76
+
77
+ def command_text(value: str) -> str:
78
+ """Redact first, then preserve layout without allowing terminal controls."""
79
+ value = _ANSI.sub("", value)
80
+ value = _PRIVATE_KEY.sub("[private key redacted]", value)
81
+ value = _QUOTED_CREDENTIAL.sub(r"\1\2[redacted]\2", value)
82
+ value = redact(_CREDENTIAL_OPTION.sub(r"\1[redacted]", value))
83
+ return "\n".join(plain(line.expandtabs(4), limit=None) for line in value.split("\n"))
84
+
85
+
86
+ _CD_PREFIX = re.compile(
87
+ r"^cd\s+(?P<dir>'[^']*'|\"[^\"]*\"|[^\s;&|]+)[ \t]*(?:&&|;|\n)[ \t\n]*",
88
+ )
89
+
90
+
91
+ def _is_current_directory(value: str) -> bool:
92
+ if len(value) > 1 and value[0] == value[-1] and value[0] in "'\"":
93
+ value = value[1:-1]
94
+ if not value or "$" in value or "`" in value:
95
+ return False
96
+ try:
97
+ return Path(value).expanduser().resolve() == Path.cwd().resolve()
98
+ except OSError:
99
+ return False
100
+
101
+
102
+ def _strip_redundant_cd(text: str) -> str:
103
+ """Drop a leading `cd <dir> &&` that only names the directory we are in.
104
+
105
+ Agents habitually prefix commands with a `cd` to the workspace root, which
106
+ costs a preview line's worth of width and says nothing. A `cd` elsewhere is
107
+ real information, so it survives.
108
+ """
109
+ while (match := _CD_PREFIX.match(text)) and _is_current_directory(match.group("dir")):
110
+ text = text[match.end() :]
111
+ return text
112
+
113
+
114
+ def command_preview(value: str) -> str:
115
+ """Structural preview, not an inferred claim about a script's purpose."""
116
+ text = _strip_redundant_cd(command_text(value).lstrip())
117
+ lines = text.splitlines()
118
+ first = next((line.strip() for line in lines if line.strip()), "(empty command)")
119
+ # Inline interpreter payloads are implementation detail, not useful labels.
120
+ inline = re.search(r"\b(?:python[\d.]*|node|ruby|perl)\s+(-c|-e)\s+", first)
121
+ if inline and (len(first) > 100 or len(lines) > 1):
122
+ first = first[: inline.end()].rstrip() + " … [inline code hidden]"
123
+ elif len(lines) > 1:
124
+ first += f" … [{len(lines) - 1} more lines]"
125
+ return plain(first, limit=100)
126
+
127
+
128
+ def tool_summary_lines(
129
+ name: str,
130
+ detail: str = "",
131
+ *,
132
+ failed: bool = False,
133
+ elapsed_seconds: float | None = None,
134
+ command: str = "",
135
+ width: int | None = None,
136
+ ) -> list[Text]:
137
+ """The settled-tool line, including any command preview, as scrollback draws it.
138
+
139
+ Shared so a browsed conversation looks like the one that scrolled past:
140
+ scrollback and the session browser differ only in how much of `detail`
141
+ they pass, which each caller decides before calling.
142
+ """
143
+ elapsed = f" · {elapsed_seconds:.1f}s" if elapsed_seconds is not None else ""
144
+ # The marker alone reports failure: a summary line keeps one style so a
145
+ # failed call does not shout louder than the diagnostic that follows it.
146
+ marker = "✗" if failed else "✓"
147
+ preview = " · " + command_preview(command) if command else ""
148
+ lines = [Text(f"{marker} {label(name)}{detail}{elapsed}{preview}", style="pcode.thinking")]
149
+ for line in lines:
150
+ line.no_wrap = True
151
+ line.overflow = "ellipsis"
152
+ if width is not None:
153
+ line.truncate(width, overflow="ellipsis")
154
+ return lines
155
+
156
+
157
+ def sandboxed_calls(code: str) -> list[str]:
158
+ """Report the tool calls a snippet makes by parsing it, not by guessing.
159
+
160
+ Names inside strings or comments cannot masquerade as calls, and a snippet
161
+ that does not parse simply reports nothing rather than a wrong summary.
162
+ """
163
+ try:
164
+ tree = ast.parse(code)
165
+ except (SyntaxError, ValueError):
166
+ return []
167
+ counts = Counter(
168
+ node.func.id
169
+ for node in ast.walk(tree)
170
+ if isinstance(node, ast.Call)
171
+ and isinstance(node.func, ast.Name)
172
+ and node.func.id in SANDBOXED_TOOLS
173
+ )
174
+ return [f"{tool} ×{count}" if count > 1 else tool for tool, count in counts.items()]
175
+
176
+
177
+ def code_preview(code: str) -> str:
178
+ """Summarize a sandboxed snippet by the calls it makes and its size."""
179
+ lines = sum(bool(line.strip()) for line in code.splitlines())
180
+ size = f"{lines} lines" if lines != 1 else "1 line"
181
+ calls = sandboxed_calls(code)
182
+ return plain(f"{' · '.join(calls)} · {size}" if calls else size, limit=100)
183
+
184
+
185
+ def stated_purpose(args: dict) -> str:
186
+ """The model's own short label for a call, sanitized, or "" when absent.
187
+
188
+ Only jobs it expects to come back to carry one, so every surface that shows
189
+ it must also work without it.
190
+ """
191
+ purpose = args.get("purpose")
192
+ if not isinstance(purpose, str) or not purpose.strip():
193
+ return ""
194
+ return plain(argument(" ".join(purpose.split())), 60)
195
+
196
+
197
+ def execution_mode(name: str, args: dict) -> str:
198
+ """How the model asked a command to run: "background", "foreground", or "".
199
+
200
+ `shell` decides per call; the sandbox tools decide by which tool was picked.
201
+ Everything else has nothing to say here, and says nothing.
202
+ """
203
+ if name == "shell":
204
+ return "background" if args.get("background") else "foreground"
205
+ if name == "start_command":
206
+ return "background"
207
+ if name == "run_command":
208
+ return "foreground"
209
+ return ""
210
+
211
+
212
+ def invocation(name: str, args: dict) -> str:
213
+ """The sanitized command a command tool ran, or "" for every other tool.
214
+
215
+ Scrollback previews a settled command from this, so an event built without
216
+ it settles as a bare "Run" row that never says what ran.
217
+ """
218
+ command = args.get("command")
219
+ if name in {"shell", "run_command", "start_command"} and isinstance(command, str):
220
+ return command_text(command)
221
+ return ""
222
+
223
+
224
+ def subject(name: str, args: dict, jobs: "JobRegistry") -> tuple[str, str]:
225
+ """The sanitized command a call is about and its stated purpose, each "" if none.
226
+
227
+ A command tool's come from its own arguments. A job tool names only an id,
228
+ so its come from the `shell` call that started that job: otherwise a wait
229
+ says a job is being waited on but never what the job runs. An id `jobs`
230
+ does not know leaves both empty rather than guessing.
231
+ """
232
+ if name in JOB_HANDLE_TOOLS:
233
+ job_id = args.get("job_id")
234
+ job = jobs.get(job_id) if isinstance(job_id, str) else None
235
+ if job is None:
236
+ return "", ""
237
+ name, args = "shell", {"command": job.command, "purpose": job.purpose}
238
+ return invocation(name, args), stated_purpose(args)
239
+
240
+
241
+ def assignment(name: str, args: dict) -> tuple[str, str]:
242
+ """A delegation's sanitized agent name and task label, or ("", "") for any other tool.
243
+
244
+ The label is the model's stated purpose when it gave one: a task usually
245
+ opens with setup ("Repository: ...") that says nothing about the work.
246
+ """
247
+ if name != "delegate_task":
248
+ return "", ""
249
+ agent = args.get("agent_name")
250
+ task = args.get("task")
251
+ return (
252
+ plain(argument(agent), 60) if isinstance(agent, str) else "agent unavailable",
253
+ stated_purpose(args)
254
+ or (plain(argument(task), 160) if isinstance(task, str) else "assignment unavailable"),
255
+ )
256
+
257
+
258
+ # Harness keys a spill `<run_id>/<tool_call_id>.<retry>`, plus `.content` for
259
+ # the content part of a `ToolReturn`.
260
+ _SPILL_HANDLE = re.compile(r"[^/]+/(.+)\.\d+(?:\.content)?")
261
+ # Harness clamps a read to this many lines (its private `_MAX_READ_LINES`).
262
+ _MAX_STORED_READ_LINES = 1000
263
+
264
+
265
+ def spilled_call_id(args: dict) -> str:
266
+ """The id of the call whose output a `read_tool_result` call reads, or "" if unknown."""
267
+ handle = args.get("handle")
268
+ match = _SPILL_HANDLE.fullmatch(handle) if isinstance(handle, str) else None
269
+ return match[1] if match else ""
270
+
271
+
272
+ def stored_source(args: dict, called: dict[str, str]) -> str:
273
+ """The tool, among `called` (call id to tool name), that spilled what `args` reads."""
274
+ return called.get(spilled_call_id(args), "")
275
+
276
+
277
+ def stored_slice(args: dict, source: str = "") -> str:
278
+ """What a `read_tool_result` call reads, in place of its opaque handle.
279
+
280
+ The handle is a run id and a provider call id, which tell a reader nothing.
281
+ The tool that produced the output and the window read from it do.
282
+ """
283
+ where = f"{plain(source, 40)} output" if source else "stored output"
284
+ pattern = args.get("pattern")
285
+ matching = isinstance(pattern, str) and bool(pattern)
286
+ if matching:
287
+ where = f"{json.dumps(plain(argument(pattern), 40), ensure_ascii=False)} in {where}"
288
+ offset, limit = args.get("offset", 0), args.get("limit", 200)
289
+ if not (isinstance(offset, int) and isinstance(limit, int)) or offset < 0 or limit < 1:
290
+ return where
291
+ limit = min(limit, _MAX_STORED_READ_LINES)
292
+ unit = "matches" if matching else "lines"
293
+ if args.get("from_end") is True:
294
+ span = (
295
+ f"last {limit} {unit}"
296
+ if not offset
297
+ else f"{unit} {offset + 1}–{offset + limit} from end"
298
+ )
299
+ else:
300
+ span = f"{unit} {offset + 1}–{offset + limit}"
301
+ return f"{where} · {span}"
302
+
303
+
304
+ def session_recall(name: str, args: dict) -> str:
305
+ """What a recall call looks for: the query searched, or the session being read."""
306
+ if name == "search_sessions":
307
+ query = args.get("query")
308
+ if not isinstance(query, str):
309
+ return "query unavailable"
310
+ parts = [json.dumps(plain(argument(query), 80), ensure_ascii=False)]
311
+ else:
312
+ session_id = args.get("session_id")
313
+ if not isinstance(session_id, str):
314
+ return "session unavailable"
315
+ title = session_name(session_id)
316
+ shown = argument(session_id)[:8] # A short id, as /resume lists them.
317
+ parts = [f"{json.dumps(title, ensure_ascii=False)} ({shown})" if title else shown]
318
+ offset = args.get("offset")
319
+ if isinstance(offset, int) and not isinstance(offset, bool) and offset > 0:
320
+ parts.append(f"from char {offset}")
321
+ scope = args.get("scope")
322
+ if isinstance(scope, str) and scope != "project":
323
+ parts.append(f"scope {plain(scope, 20)}")
324
+ if isinstance(args.get("after"), str) and args["after"].strip():
325
+ parts.append("next page")
326
+ return " · ".join(parts)
327
+
328
+
329
+ _SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,64}")
330
+ _session_names: dict[str, tuple[int, str]] = {}
331
+
332
+
333
+ def session_name(session_id: str) -> str:
334
+ """A saved session's /rename name, else its auto title, or "" if unknown.
335
+
336
+ Status rows redraw often, so the metadata is reread only when it changes:
337
+ a title named after the first lookup still shows on the next one. Only the
338
+ default session root is consulted; a session elsewhere shows its id alone.
339
+ """
340
+ if not _SESSION_ID.fullmatch(session_id):
341
+ return "" # Never let a model-supplied id walk out of the session root.
342
+ from pcode.sessions import SessionError, read_info, session_root
343
+
344
+ directory = session_root() / session_id
345
+ try:
346
+ stamp = (directory / "session.json").stat().st_mtime_ns
347
+ except OSError:
348
+ return ""
349
+ key = str(directory)
350
+ cached = _session_names.get(key)
351
+ if cached and cached[0] == stamp:
352
+ return cached[1]
353
+ try:
354
+ info = read_info(directory)
355
+ except SessionError:
356
+ title = "" # Cached too, so an unreadable file is not reparsed every redraw.
357
+ else:
358
+ title = plain(argument(info.name or info.title or ""), 60)
359
+ _session_names[key] = (stamp, title)
360
+ return title
361
+
362
+
363
+ def target(name: str, args: dict, source: str = "") -> str:
364
+ """`source` names the tool whose stored output a `read_tool_result` call reads."""
365
+ if name == "run_code":
366
+ code = args.get("code")
367
+ return code_preview(code) if isinstance(code, str) else "code unavailable"
368
+ if name == "delegate_task":
369
+ return " · ".join(assignment(name, args))
370
+ if name in {"shell", "run_command", "start_command"}:
371
+ command = args.get("command")
372
+ shown = command_preview(command) if isinstance(command, str) else "command unavailable"
373
+ # A stated purpose leads, but never replaces the command: the row has to
374
+ # keep saying what actually ran, not only what it was meant to do.
375
+ purpose = stated_purpose(args)
376
+ return f"{purpose} · {shown}" if purpose else shown
377
+ if name in JOB_HANDLE_TOOLS:
378
+ # The id is all these calls are given: without it the status line says
379
+ # a job is being waited on but not which one. `subject` adds the rest.
380
+ job_id = args.get("job_id")
381
+ return argument(job_id) if isinstance(job_id, str) else "job unavailable"
382
+ if name == "read_tool_result":
383
+ return stored_slice(args, source)
384
+ if name in {"search_sessions", "read_session"}:
385
+ return session_recall(name, args)
386
+ if name == "search_tools":
387
+ queries = args.get("queries")
388
+ if not isinstance(queries, list):
389
+ return "queries unavailable"
390
+ return ", ".join(argument(item) for item in queries if isinstance(item, str))
391
+ if name in {"get_page", "web_fetch", "web_search"}:
392
+ # `web_fetch` is Anthropic's native fetch; the local tools keep Exa's names.
393
+ key = "query" if name == "web_search" else "url"
394
+ value = args.get(key)
395
+ return argument(value) if isinstance(value, str) else f"{key} unavailable"
396
+ if name in {
397
+ "read_file",
398
+ "write_file",
399
+ "edit_file",
400
+ "search_files",
401
+ "find_files",
402
+ "list_files",
403
+ "grep",
404
+ "list_directory",
405
+ "create_directory",
406
+ "file_info",
407
+ }:
408
+ value = args.get("path", ".")
409
+ if not isinstance(value, str):
410
+ return "path unavailable"
411
+ sensitive = bool(_SENSITIVE.search(value))
412
+ path = Path(value)
413
+ if path.is_absolute():
414
+ try:
415
+ value = str(path.relative_to(Path.cwd()))
416
+ except ValueError:
417
+ pass
418
+ where = "[sensitive path]" if sensitive else plain(value)
419
+ if name in {"search_files", "find_files", "list_files", "grep"} and isinstance(
420
+ args.get("pattern"), str
421
+ ):
422
+ pattern = json.dumps(argument(args["pattern"]), ensure_ascii=False)
423
+ where = f"{pattern} in {where}"
424
+ glob = args.get("glob" if name == "grep" else "include_glob")
425
+ if name in {"search_files", "grep"} and isinstance(glob, str):
426
+ where += f" · glob {json.dumps(argument(glob), ensure_ascii=False)}"
427
+ return where
428
+ return ""
429
+
430
+
431
+ def failure_reason(content: object) -> str:
432
+ """Show actionable tool feedback, not a lossy classification of the error."""
433
+ if isinstance(content, list):
434
+ # Pydantic validation errors include raw inputs; retain their locations
435
+ # and messages without dumping those inputs into the transcript.
436
+ text = "\n".join(
437
+ f"{'.'.join(map(str, item.get('loc', ())))}: {item['msg']}".lstrip(": ")
438
+ for item in content
439
+ if isinstance(item, dict) and isinstance(item.get("msg"), str)
440
+ )
441
+ else:
442
+ text = str(content) if content is not None else ""
443
+ return command_text(text).strip() or "No error details returned."
444
+
445
+
446
+ _ANSI = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]")
447
+ _PRIVATE_KEY = re.compile(
448
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)",
449
+ re.DOTALL,
450
+ )
451
+
452
+ _QUOTED_CREDENTIAL = re.compile(
453
+ r"(?i)((?:password|passwd|secret|token|api[_-]?key|authorization)[\"']?\s*[:=]\s*)"
454
+ r"([\"'])(.*?)(?<!\\)\2",
455
+ re.DOTALL,
456
+ )
457
+
458
+
459
+ def command_error(content: object) -> str:
460
+ """Prefer stderr, or stdout for tools such as pytest; keep a bounded diagnostic tail."""
461
+ if isinstance(content, str):
462
+ text = content
463
+ elif isinstance(content, list):
464
+ # Validation errors contain an input field: show only their messages.
465
+ text = "\n".join(
466
+ item["msg"]
467
+ for item in content
468
+ if isinstance(item, dict) and isinstance(item.get("msg"), str)
469
+ )
470
+ else:
471
+ return "No error output returned."
472
+ text = re.sub(r"\n?\[exit code: -?\d+\]\s*$", "", text)
473
+ stderr = re.search(r"(?:^|\n)\[stderr\]\n(.*)", text, re.DOTALL)
474
+ if stderr and stderr[1].strip():
475
+ text = stderr[1]
476
+ else:
477
+ text = re.sub(r"(?:^|\n)\[(?:stdout|stderr)\]\n?", "\n", text)
478
+ # Remove escape sequences before redaction, then redact before clipping to avoid
479
+ # leaking pieces of a credential across formatting or excerpt boundaries.
480
+ text = _ANSI.sub("", text)
481
+ text = _PRIVATE_KEY.sub("[private key redacted]", text)
482
+ text = redact(_CREDENTIAL_OPTION.sub(r"\1[redacted]", text))
483
+ lines = [plain(line.expandtabs(4), limit=None) for line in text.splitlines()]
484
+ while lines and not lines[0].strip():
485
+ lines.pop(0)
486
+ while lines and not lines[-1].strip():
487
+ lines.pop()
488
+ if not lines or lines == ["(no output)"]:
489
+ return "No error output returned."
490
+ truncated = len(lines) > 200
491
+ excerpt = "\n".join(lines[-200:])
492
+ if len(excerpt) > 32000:
493
+ excerpt = excerpt[-32000:]
494
+ truncated = True
495
+ return ("… earlier error output truncated\n" if truncated else "") + excerpt
496
+
497
+
498
+ def result_detail(
499
+ name: str, args: dict, content: object, outcome: str, source: str = ""
500
+ ) -> tuple[str, bool]:
501
+ """Extract metrics from known Harness formats, with a safe unknown-format fallback."""
502
+ where = target(name, args, source)
503
+ text = content if isinstance(content, str) else ""
504
+ failed = outcome != "success"
505
+ # Harness 0.31 reports plan validation failures as ordinary string returns.
506
+ # Do not hide these along with successful panel-only updates.
507
+ if name in PLAN_TOOLS and (
508
+ text.startswith(
509
+ (
510
+ "Plan not updated:",
511
+ "No changes applied.",
512
+ "Invalid status ",
513
+ "Cannot ",
514
+ "A step cannot ",
515
+ )
516
+ )
517
+ or text.endswith("not found.")
518
+ ):
519
+ failed = True
520
+ if failed:
521
+ prefix = "Retry requested" if outcome == "retry" else "Failed"
522
+ result = f"{prefix} · {failure_reason(content)}"
523
+ elif text.startswith("[Tool output too large (") and name != "shell":
524
+ # Do not count the preview's lines/matches as if it were the full result.
525
+ result = "Output stored · preview in context"
526
+ elif name == "read_tool_result":
527
+ # Harness heads every slice with `[handle '…': N matching line(s); showing M]`.
528
+ match = re.match(
529
+ r"\[handle .*?: ([\d,]+) matching line\(s\); showing (\d+)(, output capped)?\]", text
530
+ )
531
+ if match:
532
+ total, shown = int(match[1].replace(",", "")), int(match[2])
533
+ noun = "line" if total == 1 else "lines"
534
+ result = f"{total:,} {noun}" if shown == total else f"{shown:,} of {total:,} {noun}"
535
+ if match[3]:
536
+ result += " · output capped"
537
+ elif text.startswith("[No stored tool result"):
538
+ result, failed = "No stored result", True
539
+ else:
540
+ result = "Read finished"
541
+ elif name == "delegate_task":
542
+ # A normal return without a lifecycle end can be a rejected delegation
543
+ # (e.g. max_calls exhausted), not proof the child completed.
544
+ result = "Returned"
545
+ elif name == "read_file":
546
+ numbers = re.findall(r"^\s*(\d+)\t", text, re.MULTILINE)
547
+ result = (
548
+ f"lines {numbers[0]}–{numbers[-1]} · {len(numbers)} lines"
549
+ if numbers
550
+ else "Read finished"
551
+ )
552
+ if re.search(r"^\.\.\. \(\d+ more lines\. Use offset=", text, re.MULTILINE):
553
+ result += " · truncated"
554
+ elif text.endswith("(empty file)\n"):
555
+ result = "0 lines · empty file"
556
+ elif name in {"search_files", "grep"}:
557
+ matches = re.findall(r"^(.+?):\d+:", text, re.MULTILINE)
558
+ result = (
559
+ f"{len(matches)} matches in {len(set(matches))} files"
560
+ if matches
561
+ else ("No matches" if text.strip() == "No matches found." else "Search finished")
562
+ )
563
+ elif name == "list_directory":
564
+ entries = text.splitlines()
565
+ files = sum(bool(re.search(r"\(\d+ bytes\)$", line)) for line in entries)
566
+ directories = sum(line.endswith("/") for line in entries)
567
+ result = (
568
+ f"{files} files · {directories} directories"
569
+ if files or directories
570
+ else "Listing finished"
571
+ )
572
+ if text == "(empty directory)":
573
+ result = "0 files · 0 directories"
574
+ elif name in {"find_files", "list_files"}:
575
+ if text.strip() == "No matches found.":
576
+ result = "No matches"
577
+ else:
578
+ count = sum(
579
+ bool(line) and not line.startswith("[... truncated") for line in text.splitlines()
580
+ )
581
+ result = f"{count} entries found"
582
+ elif name == "write_file":
583
+ match = re.match(r"Wrote (\d+) chars \((\d+) lines\)", text)
584
+ result = f"{match[2]} lines written · {match[1]} chars" if match else "Write finished"
585
+ elif name == "edit_file":
586
+ old, new = args.get("old_text"), args.get("new_text")
587
+ result = (
588
+ f"+{len(new.splitlines())} −{len(old.splitlines())} replacement lines"
589
+ if (isinstance(old, str) and isinstance(new, str))
590
+ else "Edit finished"
591
+ )
592
+ elif name in JOB_TOOLS:
593
+ # Job tools close with a `[jN · outcome · elapsed]` marker. Live `shell`
594
+ # calls use CommandFinishedEvent as the authoritative status instead.
595
+ result, failed = job_status(text)
596
+ # The summary opens with the job id, so the target would only repeat it.
597
+ if result.startswith(f"{where} "):
598
+ where = ""
599
+ elif name in {"run_command", "start_command", "check_command", "stop_command"}:
600
+ # Foreground nonzero exits and completed background processes carry this marker.
601
+ match = re.search(r"\[exit code: (-?\d+)\]\s*$", text)
602
+ if text.startswith("[Error:"):
603
+ failed = True
604
+ result = failure_reason(text)
605
+ elif re.fullmatch(r"\[Command timed out after [\d.]+s\]", text):
606
+ failed = True
607
+ result = "Timed out"
608
+ elif match:
609
+ code = int(match[1])
610
+ failed = code != 0
611
+ result = f"exit {code}" if failed else ""
612
+ if code == 127:
613
+ result += " · Executable not found"
614
+ elif text == "(no output)" or text.startswith(("[stdout]\n", "[stderr]\n")):
615
+ result = ""
616
+ else:
617
+ result = "" # No redundant success text for unknown formats.
618
+ elif name == "write_plan":
619
+ items = args.get("items")
620
+ if isinstance(items, list) and all(isinstance(item, dict) for item in items):
621
+ done = sum(item.get("status") == "completed" for item in items)
622
+ active = next(
623
+ (i + 1 for i, item in enumerate(items) if item.get("status") == "in_progress"), None
624
+ )
625
+ result = f"{len(items)} steps · {done} complete"
626
+ if active:
627
+ result += f" · working on step {active}"
628
+ else:
629
+ result = "Plan updated"
630
+ elif name == "inventory_agent_context":
631
+ try:
632
+ data = json.loads(text) if text else content
633
+ roots = data.get("roots") if isinstance(data, dict) else None
634
+ if not isinstance(roots, list) or not all(isinstance(root, dict) for root in roots):
635
+ raise ValueError
636
+ count = sum(bool(root.get("exists")) for root in roots)
637
+ result = (
638
+ f"{count} assistant configuration directories"
639
+ if count
640
+ else "No assistant configuration directories found"
641
+ )
642
+ except (ValueError, TypeError):
643
+ result = "Assistant configuration inspected"
644
+ else:
645
+ result = ""
646
+ if name in {"search_files", "find_files", "list_directory", "list_files", "grep"} and re.search(
647
+ r"^\[\.\.\. truncated at \d+ (?:entries|matches|files|lines)\]$", text, re.MULTILINE
648
+ ):
649
+ result += " · truncated"
650
+ return (f"{where} → {result}" if where and result else where or result), failed
651
+
652
+
653
+ def native_result_detail(name: str, args: dict, content: object, outcome: str) -> tuple[str, bool]:
654
+ """Summarize a provider-executed tool (native web search or fetch) from its return part.
655
+
656
+ Providers return structured content rather than Harness text: Anthropic's
657
+ search yields a list of result blocks or an error block, so count what can
658
+ be counted and otherwise only report success or failure.
659
+ """
660
+ where = target(name, args)
661
+ error = isinstance(content, dict) and str(content.get("type", "")).endswith("error")
662
+ if outcome != "success" or error:
663
+ reason = content.get("error_code", "") if isinstance(content, dict) else ""
664
+ return f"{where} → Failed" + (f" · {plain(str(reason))}" if reason else ""), True
665
+ if isinstance(content, list):
666
+ count = len(content)
667
+ return f"{where} → {count} result{'s' if count != 1 else ''}", False
668
+ return where, False
669
+
670
+
671
+ def native_result_projection(content: object) -> object:
672
+ """What `/tools` shows for a provider-executed search: the hits, minus the ciphertext.
673
+
674
+ Anthropic's native web search returns each hit with an `encrypted_content`
675
+ blob that only Anthropic can decrypt (it is replayed to the model on later
676
+ requests, never readable client-side). Listing title, URL and age is the
677
+ whole readable payload; the blob would otherwise bury it in base64.
678
+ """
679
+ if not isinstance(content, list) or not all(
680
+ isinstance(item, dict) and item.get("type") == "web_search_result" for item in content
681
+ ):
682
+ return content
683
+ if not content:
684
+ return "No results."
685
+ lines = [
686
+ f"{len(content)} result{'s' if len(content) != 1 else ''} "
687
+ "(page text is encrypted for the provider; only the model can read it)"
688
+ ]
689
+ for item in content:
690
+ lines.append("")
691
+ lines.append(str(item.get("title") or "(untitled)"))
692
+ lines.append(str(item.get("url") or "(no url)"))
693
+ if item.get("page_age"):
694
+ lines.append(f"Age: {item['page_age']}")
695
+ return "\n".join(lines)
696
+
697
+
698
+ # Tools that act on a job `shell` started, named by its id, instead of running one.
699
+ JOB_HANDLE_TOOLS = frozenset({"wait_for_job", "job_output", "stop_job"})
700
+
701
+ # Tools whose result carries a job marker, not command output of their own.
702
+ JOB_TOOLS = JOB_HANDLE_TOOLS | {"shell"}
703
+
704
+ # Shell-facing tools whose captured output can be mirrored into scrollback.
705
+ COMMAND_TOOLS = frozenset(
706
+ {"shell", "run_command", "start_command", "check_command", "stop_command"}
707
+ )
708
+
709
+ # Tools whose success is already told by a diff, so a summary row would repeat it.
710
+ EDIT_TOOLS = frozenset({"write_file", "edit_file"})
711
+
712
+
713
+ def shell_status(exit_code: int | None) -> tuple[str, bool]:
714
+ if exit_code is None:
715
+ return "Running", False
716
+ if exit_code == 0:
717
+ return "", False
718
+ return f"exit {exit_code}" + (" · Executable not found" if exit_code == 127 else ""), True
719
+
720
+
721
+ # `pcode.shell_tools` ends every job result with this marker, so one parser
722
+ # covers a finished command, an abandoned wait, and a status lookup alike.
723
+ _JOB_MARKER = re.compile(r"\[(j\d+) · (running|stopped|exit (-?\d+)) · [^]]*\]", re.MULTILINE)
724
+
725
+
726
+ # A finished job's marker, alone on the last line. Anything after the bracket
727
+ # (a stopped job's label, a still-running job's instructions) means the line
728
+ # carries more than a block heading can show, so it is left in place.
729
+ _FINISHED_MARKER = re.compile(r"(?:\A|\n)\[(j\d+) · exit -?\d+ · [^]\n]*\]\Z")
730
+
731
+
732
+ def split_outcome(output: str) -> tuple[str, str]:
733
+ """Split off a trailing `[jN · exit C · elapsed]` line, returning the job id.
734
+
735
+ A block heading already carries the outcome marker and the elapsed time, so
736
+ the job's name is all that footer adds in the UI.
737
+ """
738
+ marker = _FINISHED_MARKER.search(output)
739
+ if marker is None:
740
+ return output, ""
741
+ return output[: marker.start()], marker[1]
742
+
743
+
744
+ def job_status(text: str) -> tuple[str, bool]:
745
+ """Summarize a job result: its id, and what became of the command."""
746
+ match = None
747
+ for match in _JOB_MARKER.finditer(text):
748
+ pass # The last marker is this call's own outcome.
749
+ if match is None:
750
+ return "", False
751
+ identity, outcome, code = match[1], match[2], match[3]
752
+ if outcome == "running":
753
+ return f"{identity} · still running", False
754
+ if outcome == "stopped":
755
+ return f"{identity} · stopped", False
756
+ detail, failed = shell_status(int(code))
757
+ return f"{identity} · {detail}" if detail else "", failed
758
+
759
+
760
+ # Successful planning calls update the pinned panel; failures remain in scrollback.
761
+ PLAN_TOOLS = frozenset(
762
+ {
763
+ "write_plan",
764
+ "read_plan",
765
+ "add_task",
766
+ "update_task_status",
767
+ "update_task_statuses",
768
+ "remove_task",
769
+ "add_subtask",
770
+ "set_dependency",
771
+ "get_available_tasks",
772
+ }
773
+ )
774
+
775
+
776
+ def delegation_detail(args: dict, outcome: str) -> tuple[str, bool]:
777
+ state = {
778
+ "ok": "Completed",
779
+ "timeout": "Timed out",
780
+ "budget": "Usage budget exhausted",
781
+ "failed": "Failed",
782
+ "contained": "Child failed",
783
+ }.get(outcome, "Did not complete")
784
+ return f"{target('delegate_task', args)} → {state}", outcome != "ok"