slopper 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.
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: slopper
3
+ Version: 0.1.0
4
+ Summary: Run Claude Code and Codex agent sessions for Folio on your own machine.
5
+ Keywords: agents,claude-code,codex,folio
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Environment :: Console
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: pycrdt>=0.12.0
18
+ Requires-Dist: claude-agent-sdk<0.3,>=0.2.152
19
+ Requires-Dist: openai-codex-cli-bin<0.157,>=0.156
20
+ Requires-Dist: httpx>=0.28.0
21
+ Requires-Dist: websockets>=13.0
22
+ Requires-Dist: pydantic-settings>=2.2.0
23
+ Requires-Dist: python-json-logger>=2.0.0
24
+
25
+ # slopper
26
+
27
+ Run Folio agent sessions — Claude Code or Codex — on your own machine.
28
+
29
+ `slopper` is the environment a Folio session runs in. It registers with your
30
+ Folio server, picks up the sessions assigned to it, checks out the project's
31
+ repositories into a workspace per session, and runs the agent there. Every
32
+ tool call that needs your approval shows up in the session in Folio; the
33
+ agent waits until you answer.
34
+
35
+ Runs on **macOS** and **Linux**, Python 3.11+. Both agent CLIs are installed
36
+ with the package.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ uv tool install slopper # or: pipx install slopper
42
+ ```
43
+
44
+ ## Set up
45
+
46
+ 1. **Sign the agents in**, once per machine, as the user the service will run as:
47
+
48
+ ```bash
49
+ claude login # Claude Code
50
+ codex login # Codex (optional)
51
+ ```
52
+
53
+ 2. **Point it at Folio** with an API key from Folio's settings:
54
+
55
+ ```bash
56
+ slopper configure --server https://api.folio.example.com --token <api-key>
57
+ ```
58
+
59
+ This writes `~/.slopper/config.toml`, readable only by you.
60
+
61
+ 3. **Check everything**:
62
+
63
+ ```bash
64
+ slopper doctor
65
+ ```
66
+
67
+ 4. **Run it as a service** — started now, at every login, and again if it crashes:
68
+
69
+ ```bash
70
+ slopper service install
71
+ ```
72
+
73
+ - **macOS:** a LaunchAgent (`~/Library/LaunchAgents/slopper.plist`) in your
74
+ login session, so it can use the Claude login in your Keychain.
75
+ Logs: `~/Library/Logs/slopper/slopper.log`.
76
+ - **Linux:** a systemd user unit (`~/.config/systemd/user/slopper.service`).
77
+ Logs: `journalctl --user -u slopper`. To keep it running while you are
78
+ logged out (a headless machine): `sudo loginctl enable-linger $USER`.
79
+
80
+ ## Commands
81
+
82
+ | Command | |
83
+ |---|---|
84
+ | `slopper configure` | Write `~/.slopper/config.toml` (`--server`, `--token`, `--env-id`, `--workspace`) |
85
+ | `slopper doctor` | Check the config, the server, the key, git, and both agents' logins |
86
+ | `slopper service install` · `uninstall` | Install or remove the service |
87
+ | `slopper service start` · `stop` · `restart` · `status` | Control it |
88
+ | `slopper service logs [-f]` | Show (or follow) its log |
89
+ | `slopper run` | Run in the foreground instead of as a service |
90
+
91
+ Only one environment runs per user account; a second `slopper run` refuses to
92
+ start while the service is running.
93
+
94
+ ## Upgrade
95
+
96
+ ```bash
97
+ uv tool upgrade slopper && slopper service restart
98
+ ```
99
+
100
+ The service runs the installed interpreter by path, so it picks up the new
101
+ version on restart — there is nothing to reinstall.
102
+
103
+ ## Configuration
104
+
105
+ `~/.slopper/config.toml` holds the settings; environment variables of the
106
+ same name override it.
107
+
108
+ | Setting | Default | |
109
+ |---|---|---|
110
+ | `SLOPPER_SERVER_URL` | `http://localhost:8000` | Folio API |
111
+ | `SLOPPER_TOKEN` | — | API key this environment registers with |
112
+ | `SLOPPER_ENV_ID` | generated once, kept in `~/.slopper/identity.toml` | This machine's name in Folio. Must stay stable: sessions are bound to it |
113
+ | `SLOPPER_WORKSPACE_ROOT` | `~/.slopper/workspaces` | One directory per session |
114
+ | `SLOPPER_CLONE_SINCE` | `1.month.ago` | How much git history a checkout carries; empty for all |
115
+ | `SLOPPER_CODEX_BIN` | the bundled one | A different `codex` binary |
@@ -0,0 +1,34 @@
1
+ slopper_env/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ slopper_env/__main__.py,sha256=sQ6a1m5lUARAT4diQ1vWB8Q45Yf1MBkdiTss4gMy6-0,132
3
+ slopper_env/agent.py,sha256=PKnsx8bynTskyUyrMKYKxXBHTfVcvSDxJSofhlFE_Js,6642
4
+ slopper_env/cli.py,sha256=mZ4uL9MBrwLFNbhvdnvoQeiJOjUNd2lDaIknf6bfmYo,11392
5
+ slopper_env/config.py,sha256=-kmfqpsYNo3AN3ZL9m0kQlCn1Ak5ovaBwsbfbqQzs5c,4064
6
+ slopper_env/constants.py,sha256=h009BxGnp2A-SyImMWfyyB0AIiQ6tgnU6zQud9oRqTE,660
7
+ slopper_env/devtools.py,sha256=mTPMZlPtUWsqMPBpC8r9OY5WU2b4cuYIHxrtkol5_xY,7021
8
+ slopper_env/doc.py,sha256=m7Y0lbJjMY7YAJ5llwmiw9LhnJ7P1EIDEDq4qDtP7-M,59804
9
+ slopper_env/folio_mcp.py,sha256=3LCgnLO_3_pYHVgvwT_GsUww6VG1_eAMRI1IofAyOFE,2813
10
+ slopper_env/identity.py,sha256=8cPZ77kCX1QufJpLkN5jWqZb26dswibGoFeD5-aIW-0,2707
11
+ slopper_env/ids.py,sha256=PiDUwk7MZEo1ULNn7UJwWf8H8UNnAMsFG3l9SsrOCD8,858
12
+ slopper_env/lock.py,sha256=gylCYceT1Dvo2q6lFmTpUBYWqvKIVp-Y3A_TpiDJgoM,1882
13
+ slopper_env/loop.py,sha256=w2MXysbGJ4KlAgA_7KDtHr1R36bZgEZdo-cqTWt_r2k,8865
14
+ slopper_env/main.py,sha256=39s7o3jKeg7LEUf5yFr-4-vi7-lGq93xwCRhcoKx_1c,13420
15
+ slopper_env/models.py,sha256=KadlFWHBujTtNZD26f0cjvns-Gmob0sZn9pfblt4sTs,4951
16
+ slopper_env/ordering.py,sha256=A7pWh1DkXDrLC9OZaS_dl3wXBeXijApTWMo8d84VmzA,4884
17
+ slopper_env/registration.py,sha256=yaD-KjyfGSLeoFso2RVb7HKg51GiPIgPsiNMwFTRIYs,4833
18
+ slopper_env/runner.py,sha256=aiaAAptSO4rwtkvcvfdKhyZaQyzAmf2GsBnpvnoFW-M,8411
19
+ slopper_env/service.py,sha256=c6f7ebkAxzd_WhZQLeI2ySQ3CBYZqez-c2cR6RVcLQk,8940
20
+ slopper_env/sync.py,sha256=bZD1lLekKRM8Mr2_T8KmY4zbIq15zZTP6I4ZYbAukj4,8364
21
+ slopper_env/workspace.py,sha256=996xqKQ7uoCgFr5IaYCilQ8kRoO4Fu_IaSjWs_AX4XA,13331
22
+ slopper_env/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
+ slopper_env/adapters/base.py,sha256=xhhvq4qADuA-N2SDPkh1LQ5ljBY3r0QCBT4I5xwYdO4,1386
24
+ slopper_env/adapters/claude_code.py,sha256=nB7Ylb6zg8V5hPfYscGD1-56lRNDyA24XcJaPvIyP08,6807
25
+ slopper_env/adapters/codex.py,sha256=_D5gJZJ9n-6VVRLu4tLJoA65JL7R1VFKNI8B88NxXkY,8425
26
+ slopper_env/backends/__init__.py,sha256=mpcZ7wXN910ZCq3hvzf6LiynPEd-D6Mu1v8ncMsJBv4,719
27
+ slopper_env/backends/base.py,sha256=Uz3c-awxLYpb8NuLnPPUUS3oaE9PSOBEwLT-TB2-xv8,2690
28
+ slopper_env/backends/claude.py,sha256=9oC3FDY8efFSOZFJwAbwzfS4ebjrgPPvLDybWFR_Awk,8341
29
+ slopper_env/backends/codex.py,sha256=IE7NXo_7C3K3zU7mKtKocdqYQ9p67Sl5cx9KiVEIEOA,18153
30
+ slopper-0.1.0.dist-info/METADATA,sha256=DUkVAP92RMxiPnUJD8X_QvFV0ldbvKCpLr-u8bkRFi4,4099
31
+ slopper-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
+ slopper-0.1.0.dist-info/entry_points.txt,sha256=GmIIHP2kAcn_QmEGsMYzuyOKBMgYGutw4lg79zjbMvI,128
33
+ slopper-0.1.0.dist-info/top_level.txt,sha256=WqIoIhel29y542bultciwMbCXyXonN5B8atEYBy-NgU,12
34
+ slopper-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ slopper = slopper_env.cli:main
3
+ slopper-dev = slopper_env.devtools:cli
4
+ slopper-env = slopper_env.cli:run_alias
@@ -0,0 +1 @@
1
+ slopper_env
File without changes
@@ -0,0 +1,7 @@
1
+ """``python -m slopper_env`` -- what the installed service runs."""
2
+
3
+ import sys
4
+
5
+ from slopper_env.cli import main
6
+
7
+ sys.exit(main())
File without changes
@@ -0,0 +1,44 @@
1
+ """The provider seam.
2
+
3
+ This interface is the only place in the environment where a provider is
4
+ named. ``TurnRunner``, ``ConversationLoop``, the document, and the UI all
5
+ operate on canonical items alone.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from typing import Any, Sequence
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class TurnEnd:
17
+ """The provider's account of how a turn finished, in neutral terms."""
18
+
19
+ #: The provider's own session (Claude) or thread (Codex) id, recorded so
20
+ #: a later attach can resume it.
21
+ session_id: str | None
22
+ #: The turn ended on a tool call waiting for a human, to be resumed.
23
+ deferred: bool = False
24
+
25
+
26
+ class AgentAdapter(ABC):
27
+ kind: str
28
+
29
+ @abstractmethod
30
+ def from_provider(self, message: Any) -> list[dict]:
31
+ """Translate one provider message into canonical items.
32
+
33
+ Returns a list because a single provider message may carry several
34
+ canonical items, and because some provider messages (usage reports,
35
+ lifecycle chatter) carry none.
36
+ """
37
+
38
+ @abstractmethod
39
+ def turn_end(self, message: Any) -> TurnEnd | None:
40
+ """The turn's outcome if ``message`` is the one that ends it."""
41
+
42
+ @abstractmethod
43
+ def to_provider(self, items: Sequence[dict]) -> Any:
44
+ """Translate canonical history into a provider payload."""
@@ -0,0 +1,184 @@
1
+ """Claude Code adapter.
2
+
3
+ Near-identity in the ingest direction: the canonical content model is
4
+ Anthropic-shaped, and ``claude_agent_sdk`` emits exactly those blocks.
5
+
6
+ The one restructuring happens on egress. Slopper stores tool output under
7
+ ``role="tool"``; the Messages API has no such role and requires it to travel
8
+ inside a user message. That re-encoding lives here and nowhere else, so the
9
+ stored transcript never claims a user said something they did not.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Sequence
15
+
16
+ import claude_agent_sdk as sdk
17
+
18
+ from slopper_env.adapters.base import AgentAdapter, TurnEnd
19
+ from slopper_env.ids import new_id
20
+ from slopper_env.models import BlockType, ItemType, NON_CONTENT_ITEM_TYPES, Role
21
+
22
+
23
+ class ClaudeCodeAdapter(AgentAdapter):
24
+ kind = "claude-code"
25
+
26
+ # ------------------------------------------------------------- ingest
27
+
28
+ def from_provider(self, message: Any) -> list[dict]:
29
+ if isinstance(message, sdk.AssistantMessage):
30
+ return self._assistant(message)
31
+ if isinstance(message, sdk.UserMessage):
32
+ return self._tool_results(message)
33
+ if isinstance(message, sdk.ResultMessage):
34
+ return [] # usage/cost is attached to the run, not a transcript item
35
+ return []
36
+
37
+ def turn_end(self, message: Any) -> TurnEnd | None:
38
+ """The session id rides on the terminal ResultMessage and nowhere else."""
39
+ if not isinstance(message, sdk.ResultMessage):
40
+ return None
41
+ return TurnEnd(
42
+ session_id=getattr(message, "session_id", None),
43
+ deferred=getattr(message, "stop_reason", None) == "tool_deferred",
44
+ )
45
+
46
+ def _assistant(self, message: sdk.AssistantMessage) -> list[dict]:
47
+ content = [self._block(block) for block in message.content]
48
+ content = [block for block in content if block is not None]
49
+ if not content:
50
+ return []
51
+ return [
52
+ {
53
+ "id": new_id(),
54
+ "type": ItemType.MESSAGE,
55
+ "role": Role.ASSISTANT,
56
+ "model": getattr(message, "model", None),
57
+ "content": content,
58
+ }
59
+ ]
60
+
61
+ def _tool_results(self, message: sdk.UserMessage) -> list[dict]:
62
+ """Tool output arrives from the SDK as a UserMessage.
63
+
64
+ That is the wire format's constraint, not a fact about who produced
65
+ it, so it is stored under ``role="tool"``.
66
+ """
67
+ if not isinstance(message.content, list):
68
+ return []
69
+ blocks = [
70
+ self._block(block)
71
+ for block in message.content
72
+ if isinstance(block, sdk.ToolResultBlock)
73
+ ]
74
+ blocks = [block for block in blocks if block is not None]
75
+ if not blocks:
76
+ return []
77
+ return [
78
+ {
79
+ "id": new_id(),
80
+ "type": ItemType.MESSAGE,
81
+ "role": Role.TOOL,
82
+ "content": blocks,
83
+ }
84
+ ]
85
+
86
+ def _block(self, block: Any) -> dict | None:
87
+ if isinstance(block, sdk.TextBlock):
88
+ return {"type": BlockType.TEXT, "text": block.text}
89
+
90
+ if isinstance(block, sdk.ThinkingBlock):
91
+ # `signature` must survive verbatim: Claude requires it back to
92
+ # accept a thinking block across tool-use turns. Never let a
93
+ # "normalize the block" helper drop it.
94
+ return {
95
+ "type": BlockType.THINKING,
96
+ "text": block.thinking,
97
+ "signature": block.signature,
98
+ }
99
+
100
+ if isinstance(block, sdk.ToolUseBlock):
101
+ return {
102
+ "type": BlockType.TOOL_USE,
103
+ "id": block.id,
104
+ "name": block.name,
105
+ "input": block.input,
106
+ }
107
+
108
+ if isinstance(block, sdk.ToolResultBlock):
109
+ return {
110
+ "type": BlockType.TOOL_RESULT,
111
+ "tool_use_id": block.tool_use_id,
112
+ "content": block.content,
113
+ "is_error": bool(block.is_error),
114
+ }
115
+
116
+ return None
117
+
118
+ # ------------------------------------------------------------- egress
119
+
120
+ def to_provider(self, items: Sequence[dict]) -> list[dict]:
121
+ """Rebuild Anthropic-format history from canonical items.
122
+
123
+ Not on the hot path -- ``ClaudeSDKClient`` owns session history, so a
124
+ normal turn sends new input only. This runs for cold resume.
125
+
126
+ Items that are transcript metadata rather than conversation content
127
+ are dropped here. Replaying an error banner would have the agent
128
+ responding to its own infrastructure failure as though the user had
129
+ reported it.
130
+ """
131
+ out: list[dict] = []
132
+ for item in items:
133
+ item_type = item.get("type")
134
+ if item_type in NON_CONTENT_ITEM_TYPES:
135
+ continue
136
+ if item_type == ItemType.RUN:
137
+ out.extend(self.to_provider(item.get("items") or []))
138
+ continue
139
+ if item_type != ItemType.MESSAGE:
140
+ continue
141
+ if item.get("is_meta") and item.get("role") == Role.SYSTEM:
142
+ continue
143
+
144
+ role = item.get("role")
145
+ content = [self._to_wire(b) for b in (item.get("content") or [])]
146
+ content = [b for b in content if b is not None]
147
+ if not content:
148
+ continue
149
+
150
+ # The Messages API has no tool role; tool output rides in a user
151
+ # message. Storage keeps them distinct, the wire does not.
152
+ wire_role = "user" if role == Role.TOOL else role
153
+ if wire_role not in ("user", "assistant"):
154
+ continue
155
+ out.append({"role": wire_role, "content": content})
156
+ return out
157
+
158
+ def _to_wire(self, block: dict) -> dict | None:
159
+ kind = block.get("type")
160
+ if kind == BlockType.TEXT:
161
+ return {"type": "text", "text": str(block.get("text", ""))}
162
+ if kind == BlockType.THINKING:
163
+ return {
164
+ "type": "thinking",
165
+ "thinking": block.get("text", ""),
166
+ "signature": block.get("signature", ""),
167
+ }
168
+ if kind == BlockType.TOOL_USE:
169
+ return {
170
+ "type": "tool_use",
171
+ "id": block.get("id"),
172
+ "name": block.get("name"),
173
+ "input": block.get("input") or {},
174
+ }
175
+ if kind == BlockType.TOOL_RESULT:
176
+ wire = {
177
+ "type": "tool_result",
178
+ "tool_use_id": block.get("tool_use_id"),
179
+ "content": block.get("content"),
180
+ }
181
+ if block.get("is_error"):
182
+ wire["is_error"] = True
183
+ return wire
184
+ return None
@@ -0,0 +1,220 @@
1
+ """Codex adapter.
2
+
3
+ Codex reports a turn as *items* -- an agent message, a reasoning summary, a
4
+ command it ran, a file change, an MCP call -- each announced by
5
+ ``item/started`` and finished by ``item/completed`` over the app-server
6
+ protocol. This turns them into the same canonical items the Claude adapter
7
+ produces, so the document and the UI stay provider-blind:
8
+
9
+ agentMessage -> assistant text
10
+ reasoning -> assistant thinking (summary text, no signature)
11
+ commandExecution -> tool_use "shell" + tool_result
12
+ fileChange -> tool_use "apply_patch" + tool_result
13
+ mcpToolCall -> tool_use "mcp__<server>__<tool>" + tool_result
14
+ webSearch -> tool_use "web_search" + tool_result
15
+
16
+ A tool call is written when it *starts*, so the transcript shows it running
17
+ (and an approval card has a row to belong to); its result is written when it
18
+ completes. Codex's item id is the tool_use id on both, which is also the id
19
+ the approval request carries.
20
+
21
+ Egress is not implemented: a Codex session's history lives in its own
22
+ thread, resumed by id. Replaying canonical history into Codex is only needed
23
+ for cross-provider forks, which do not exist yet.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any, Sequence
29
+
30
+ from slopper_env.adapters.base import AgentAdapter, TurnEnd
31
+ from slopper_env.ids import new_id
32
+ from slopper_env.models import BlockType, ItemType, Role
33
+
34
+ #: Canonical tool names for Codex's built-in actions.
35
+ SHELL = "shell"
36
+ APPLY_PATCH = "apply_patch"
37
+ WEB_SEARCH = "web_search"
38
+
39
+ #: Item statuses that mean the action did not happen as asked.
40
+ _FAILED = {"failed", "declined"}
41
+
42
+
43
+ class CodexAdapter(AgentAdapter):
44
+ kind = "codex"
45
+
46
+ # ------------------------------------------------------------- ingest
47
+
48
+ def from_provider(self, message: Any) -> list[dict]:
49
+ method = message.get("method")
50
+ params = message.get("params") or {}
51
+ if method == "item/started":
52
+ call = tool_call(params.get("item") or {})
53
+ return [_assistant([call])] if call else []
54
+ if method == "item/completed":
55
+ return self._completed(params.get("item") or {})
56
+ if method == "turn/completed":
57
+ return self._failure(params.get("turn") or {})
58
+ return []
59
+
60
+ def turn_end(self, message: Any) -> TurnEnd | None:
61
+ if message.get("method") != "turn/completed":
62
+ return None
63
+ return TurnEnd(session_id=(message.get("params") or {}).get("threadId"))
64
+
65
+ def _completed(self, item: dict) -> list[dict]:
66
+ kind = item.get("type")
67
+ if kind == "agentMessage":
68
+ text = item.get("text") or ""
69
+ return [_assistant([{"type": BlockType.TEXT, "text": text}])] if text else []
70
+ if kind == "reasoning":
71
+ text = "\n\n".join(_strings(item.get("summary")))
72
+ if not text:
73
+ return []
74
+ # No signature: that is an Anthropic concept. The block is for the
75
+ # reader; Codex keeps its own reasoning in its thread.
76
+ return [_assistant([{"type": BlockType.THINKING, "text": text, "signature": ""}])]
77
+ result = tool_result(item)
78
+ if result is None:
79
+ return []
80
+ return [{"id": new_id(), "type": ItemType.MESSAGE, "role": Role.TOOL, "content": [result]}]
81
+
82
+ def _failure(self, turn: dict) -> list[dict]:
83
+ if turn.get("status") != "failed":
84
+ return []
85
+ error = turn.get("error") or {}
86
+ message = error.get("message") if isinstance(error, dict) else str(error)
87
+ return [
88
+ {
89
+ "type": ItemType.ERROR,
90
+ "source": "agent",
91
+ "code": "turn_failed",
92
+ "message": (message or "Codex reported the turn as failed.")[:2000],
93
+ }
94
+ ]
95
+
96
+ # ------------------------------------------------------------- egress
97
+
98
+ def to_provider(self, items: Sequence[dict]) -> Any:
99
+ raise NotImplementedError("Codex resumes its own thread; see module docstring")
100
+
101
+
102
+ # ------------------------------------------------------------------ mapping
103
+
104
+
105
+ def tool_call(item: dict) -> dict | None:
106
+ """The canonical tool_use block for a Codex item, or None if it is not one."""
107
+ kind = item.get("type")
108
+ item_id = item.get("id")
109
+ if not item_id:
110
+ return None
111
+ if kind == "commandExecution":
112
+ name, tool_input = SHELL, {"command": command_text(item)}
113
+ elif kind == "fileChange":
114
+ changes = [
115
+ {"path": c.get("path"), "kind": _kind(c.get("kind"))}
116
+ for c in item.get("changes") or []
117
+ ]
118
+ tool_input = {"changes": changes}
119
+ if changes:
120
+ # Named so the transcript labels the row by its file.
121
+ tool_input["path"] = changes[0]["path"]
122
+ name = APPLY_PATCH
123
+ elif kind == "mcpToolCall":
124
+ name = f"mcp__{item.get('server')}__{item.get('tool')}"
125
+ arguments = item.get("arguments")
126
+ tool_input = arguments if isinstance(arguments, dict) else {"arguments": arguments}
127
+ elif kind == "webSearch":
128
+ name, tool_input = WEB_SEARCH, {"query": item.get("query") or ""}
129
+ else:
130
+ return None
131
+ return {"type": BlockType.TOOL_USE, "id": item_id, "name": name, "input": tool_input}
132
+
133
+
134
+ def tool_result(item: dict) -> dict | None:
135
+ """The canonical tool_result block for a finished Codex item."""
136
+ kind = item.get("type")
137
+ item_id = item.get("id")
138
+ if not item_id or kind not in ("commandExecution", "fileChange", "mcpToolCall", "webSearch"):
139
+ return None
140
+
141
+ failed = item.get("status") in _FAILED
142
+ if kind == "commandExecution":
143
+ exit_code = item.get("exitCode")
144
+ content = item.get("aggregatedOutput") or ""
145
+ if exit_code not in (None, 0):
146
+ failed = True
147
+ content = f"{content}\n(exit code {exit_code})".strip()
148
+ if item.get("status") == "declined":
149
+ content = content or "The user declined this."
150
+ elif kind == "fileChange":
151
+ content = "\n".join(
152
+ f"{_kind(c.get('kind'))} {c.get('path')}\n{c.get('diff') or ''}".rstrip()
153
+ for c in item.get("changes") or []
154
+ )
155
+ if item.get("status") == "declined":
156
+ content = "The user declined this."
157
+ elif kind == "mcpToolCall":
158
+ error = item.get("error")
159
+ if error:
160
+ failed = True
161
+ content = error.get("message") if isinstance(error, dict) else str(error)
162
+ else:
163
+ content = _mcp_text(item.get("result"))
164
+ else:
165
+ content = ""
166
+ return {
167
+ "type": BlockType.TOOL_RESULT,
168
+ "tool_use_id": item_id,
169
+ "content": content,
170
+ "is_error": failed,
171
+ }
172
+
173
+
174
+ def command_text(item: dict) -> str:
175
+ """The command as the agent wrote it, not as Codex wrapped it.
176
+
177
+ Codex runs ``printf hi > f`` as ``/bin/zsh -lc 'printf hi > f'``; its
178
+ parsed ``commandActions`` keep the original, which is what a reader
179
+ recognises and what an approval card should show.
180
+ """
181
+ actions = item.get("commandActions") or []
182
+ if len(actions) == 1 and actions[0].get("command"):
183
+ return str(actions[0]["command"])
184
+ return str(item.get("command") or "")
185
+
186
+
187
+ def _assistant(content: list[dict]) -> dict:
188
+ return {"id": new_id(), "type": ItemType.MESSAGE, "role": Role.ASSISTANT, "content": content}
189
+
190
+
191
+ def _kind(kind: Any) -> str:
192
+ """A file change's kind: a plain string, or ``{"type": "update", ...}``."""
193
+ if isinstance(kind, dict):
194
+ return str(kind.get("type") or "update")
195
+ return str(kind or "update")
196
+
197
+
198
+ def _strings(value: Any) -> list[str]:
199
+ if isinstance(value, str):
200
+ return [value] if value else []
201
+ if isinstance(value, list):
202
+ out = []
203
+ for part in value:
204
+ text = part.get("text") if isinstance(part, dict) else part
205
+ if isinstance(text, str) and text:
206
+ out.append(text)
207
+ return out
208
+ return []
209
+
210
+
211
+ def _mcp_text(result: Any) -> str:
212
+ """MCP results carry content blocks; keep their text."""
213
+ if not isinstance(result, dict):
214
+ return "" if result is None else str(result)
215
+ texts = [
216
+ block.get("text", "")
217
+ for block in result.get("content") or []
218
+ if isinstance(block, dict) and block.get("type") == "text"
219
+ ]
220
+ return "\n".join(texts) if texts else str(result.get("structuredContent") or "")