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