agent-sessions-cli 0.2.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,3 @@
1
+ """agent-sessions: share coding-agent sessions with your team through git."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import entry
2
+
3
+ entry()
@@ -0,0 +1,136 @@
1
+ """Adapter registry and the live-session identification algorithm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Dict, List, Optional, Tuple
7
+
8
+ from ..model import PUSH_MARKER_RE
9
+ from .base import Adapter, SessionRef, recent
10
+ from .claude_code import ClaudeCodeAdapter
11
+
12
+ REGISTRY: Dict[str, Adapter] = {}
13
+
14
+
15
+ def register(adapter: Adapter) -> None:
16
+ REGISTRY[adapter.name] = adapter
17
+
18
+
19
+ register(ClaudeCodeAdapter())
20
+
21
+ try: # optional adapters are added as they land; a broken import must not take the tool down
22
+ from .codex import CodexAdapter
23
+ register(CodexAdapter())
24
+ except ImportError: # pragma: no cover
25
+ pass
26
+ try:
27
+ from .opencode import OpenCodeAdapter
28
+ register(OpenCodeAdapter())
29
+ except ImportError: # pragma: no cover
30
+ pass
31
+ try:
32
+ from .gemini import GeminiAdapter
33
+ register(GeminiAdapter())
34
+ except ImportError: # pragma: no cover
35
+ pass
36
+
37
+ AGENT_CHOICES = ["auto"] + list(REGISTRY.keys())
38
+ RECENT_MINUTES = 15
39
+
40
+
41
+ class Resolution:
42
+ def __init__(self, ref: Optional[SessionRef], how: str, candidates: List[SessionRef], adapter: Optional[Adapter]) -> None:
43
+ self.ref, self.how, self.candidates, self.adapter = ref, how, candidates, adapter
44
+
45
+
46
+ def installed_adapters(agent: str = "auto") -> List[Adapter]:
47
+ if agent != "auto":
48
+ if agent not in REGISTRY:
49
+ raise KeyError(agent)
50
+ return [REGISTRY[agent]]
51
+ return [a for a in REGISTRY.values() if a.installed()]
52
+
53
+
54
+ def resolve(agent: str, session_id: Optional[str], repo_root: str) -> Resolution:
55
+ """Find the live session. Never picks silently between several plausible ones."""
56
+ adapters = installed_adapters(agent)
57
+ if session_id:
58
+ for a in adapters:
59
+ ref = a.locate_by_id(session_id)
60
+ if ref:
61
+ return Resolution(ref, "session id", [], a)
62
+ # known id, no data yet: fresh session. Pick the adapter the environment proves, else the explicit one.
63
+ owner = REGISTRY[agent] if agent != "auto" else _env_agent(adapters)
64
+ if owner:
65
+ return Resolution(owner.pending_ref(session_id), "not written yet", [], owner)
66
+ return Resolution(None, "not found", [], None)
67
+
68
+ for a in adapters:
69
+ env_id = a.in_env()
70
+ if env_id:
71
+ ref = a.locate_by_id(env_id)
72
+ if ref:
73
+ return Resolution(ref, f"environment ({a.name})", [], a)
74
+ return Resolution(a.pending_ref(env_id), "not written yet", [], a)
75
+
76
+ all_cands: List[Tuple[Adapter, SessionRef]] = []
77
+ for a in adapters:
78
+ try:
79
+ for ref in a.candidates(repo_root, limit=5):
80
+ all_cands.append((a, ref))
81
+ except Exception: # noqa: BLE001 - one broken store must not hide the others
82
+ continue
83
+ all_cands.sort(key=lambda ar: ar[1].updated.timestamp() if ar[1].updated else 0, reverse=True)
84
+
85
+ # self-referencing locate call still in flight
86
+ hits = []
87
+ for a, ref in all_cands:
88
+ if not recent(ref, RECENT_MINUTES):
89
+ continue
90
+ try:
91
+ sess = a.load(ref)
92
+ except Exception: # noqa: BLE001
93
+ continue
94
+ ref.extra["session"] = sess
95
+ ref.title = ref.title or sess.title
96
+ if sess.has_pending_self_call():
97
+ hits.append((a, ref))
98
+ if len(hits) == 1:
99
+ return Resolution(hits[0][1], "self-referencing locate call", [], hits[0][0])
100
+
101
+ # push marker in the latest user turn
102
+ marks = []
103
+ for a, ref in all_cands:
104
+ sess = ref.extra.get("session")
105
+ if sess is None:
106
+ try:
107
+ sess = a.load(ref)
108
+ ref.extra["session"] = sess
109
+ ref.title = ref.title or sess.title
110
+ except Exception: # noqa: BLE001
111
+ continue
112
+ if PUSH_MARKER_RE.search(sess.last_user_text() or "") or any(e.is_push_invocation for e in sess.events[-3:]):
113
+ marks.append((a, ref))
114
+ if len(marks) == 1:
115
+ return Resolution(marks[0][1], "push marker in latest user turn", [], marks[0][0])
116
+
117
+ if len(all_cands) == 1:
118
+ a, ref = all_cands[0]
119
+ how = "only recent session for this repo" if recent(ref, RECENT_MINUTES) else "only session for this repo (not recent; check it is the right one)"
120
+ return Resolution(ref, how, [], a)
121
+ if not all_cands:
122
+ return Resolution(None, "not found", [], None)
123
+ return Resolution(None, "ambiguous", [ref for _, ref in all_cands], None)
124
+
125
+
126
+ def _env_agent(adapters: List[Adapter]) -> Optional[Adapter]:
127
+ for a in adapters:
128
+ if a.in_env() is not None:
129
+ return a
130
+ return None
131
+
132
+
133
+ def adapter_for(agent: str) -> Adapter:
134
+ if agent not in REGISTRY:
135
+ raise KeyError(agent)
136
+ return REGISTRY[agent]
@@ -0,0 +1,108 @@
1
+ """Adapter contract shared by every agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from typing import List, Optional, Tuple
9
+
10
+ from ..model import Session
11
+
12
+
13
+ @dataclass
14
+ class SessionRef:
15
+ agent: str
16
+ session_id: str
17
+ short_id: str
18
+ source: str # file path or "sqlite:<db>#<id>"; "" when not on disk yet
19
+ cwd: Optional[str] = None
20
+ updated: Optional[dt.datetime] = None
21
+ title: Optional[str] = None
22
+ extra: dict = field(default_factory=dict)
23
+
24
+ @property
25
+ def on_disk(self) -> bool:
26
+ return bool(self.source)
27
+
28
+
29
+ class Adapter:
30
+ name = "base"
31
+ label = "Base"
32
+ env_ids: Tuple[str, ...] = () # env vars carrying the session id
33
+ env_presence: Tuple[str, ...] = () # env vars that merely prove we run inside this agent
34
+ tested_version: Optional[Tuple[int, ...]] = None
35
+
36
+ def data_dir(self) -> str:
37
+ raise NotImplementedError
38
+
39
+ def installed(self) -> bool:
40
+ return os.path.exists(self.data_dir())
41
+
42
+ def short_id(self, session_id: str) -> str:
43
+ return session_id[:8]
44
+
45
+ def locate_by_id(self, session_id: str) -> Optional[SessionRef]:
46
+ raise NotImplementedError
47
+
48
+ def candidates(self, repo_root: str, limit: int = 5) -> List[SessionRef]:
49
+ raise NotImplementedError
50
+
51
+ def load(self, ref: SessionRef) -> Session:
52
+ raise NotImplementedError
53
+
54
+ def ref_from_source(self, source: str) -> Optional[SessionRef]:
55
+ """Build a ref from a SOURCE string this adapter printed earlier, or None if it is not ours."""
56
+ return None
57
+
58
+ def pending_ref(self, session_id: str) -> SessionRef:
59
+ """A session we know the id of but that has no data on disk yet."""
60
+ return SessionRef(self.name, session_id, self.short_id(session_id), "")
61
+
62
+ def in_env(self) -> Optional[str]:
63
+ """Session id from the environment, or "" if the environment proves the agent but not the id."""
64
+ for var in self.env_ids:
65
+ val = os.environ.get(var, "").strip()
66
+ if val and not val.startswith("${"):
67
+ return val
68
+ for var in self.env_presence:
69
+ if os.environ.get(var):
70
+ return ""
71
+ return None
72
+
73
+
74
+ def generic_label(name: str, kind: str, inp: object, paths: List[str]) -> str:
75
+ """Default one-line label. Built from full strings; the renderer redacts then truncates."""
76
+ d = inp if isinstance(inp, dict) else {}
77
+ if kind == "shell":
78
+ cmd = d.get("command") or d.get("cmd") or (inp if isinstance(inp, str) else "")
79
+ if isinstance(cmd, list):
80
+ cmd = "; ".join(str(c) for c in cmd)
81
+ desc = str(d.get("description") or "").strip()
82
+ return f"{name}: {desc or str(cmd).strip().split(chr(10))[0]}"
83
+ if kind in ("read", "edit", "write"):
84
+ target = paths[0] if paths else (d.get("filePath") or d.get("file_path") or d.get("path") or "")
85
+ return f"{name}: {target}"
86
+ if kind == "agent":
87
+ return f"{name}: {str(d.get('description') or d.get('prompt') or '')}"
88
+ if kind == "search":
89
+ return f"{name}: {str(d.get('pattern') or d.get('query') or d.get('path') or '')}"
90
+ if kind == "web":
91
+ return f"{name}: {str(d.get('url') or d.get('query') or d.get('prompt') or '')}"
92
+ if kind == "ask":
93
+ return f"{name}"
94
+ return name
95
+
96
+
97
+ def strip_wrappers(text: str, tags: Tuple[str, ...]) -> str:
98
+ """Remove <tag ...>…</tag> blocks the harness injects around user text."""
99
+ import re
100
+ for tag in tags:
101
+ text = re.sub(rf"<{tag}\b[^>]*>[\s\S]*?</{tag}>", "", text, flags=re.I)
102
+ return text.strip()
103
+
104
+
105
+ def recent(ref: SessionRef, minutes: int) -> bool:
106
+ if not ref.updated:
107
+ return True
108
+ return (dt.datetime.now(dt.timezone.utc) - ref.updated) <= dt.timedelta(minutes=minutes)
@@ -0,0 +1,300 @@
1
+ """Claude Code: ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import os
7
+ import re
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ from ..core import config_dir, first_json_line, mtime, parse_ts, read_jsonl, rel_to_root, under, home_to_tilde
11
+ from ..model import Event, PUSH_MARKER_RE, Session, ToolCall
12
+ from .base import Adapter, SessionRef
13
+
14
+ KIND_BY_NAME = {
15
+ "Bash": "shell", "Read": "read", "Edit": "edit", "MultiEdit": "edit", "NotebookEdit": "edit", "Write": "write",
16
+ "Grep": "search", "Glob": "search", "WebFetch": "web", "WebSearch": "web", "Agent": "agent", "Task": "agent",
17
+ "AskUserQuestion": "ask", "ReadMcpResourceTool": "mcp", "ReadMcpResourceDirTool": "mcp",
18
+ }
19
+ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
20
+
21
+ SYSTEM_NOISE_RE = re.compile(
22
+ r"<system-reminder>[\s\S]*?</system-reminder>|<local-command-caveat>[\s\S]*?</local-command-caveat>"
23
+ r"|<local-command-stdout>[\s\S]*?</local-command-stdout>|<command-message>[\s\S]*?</command-message>",
24
+ re.I,
25
+ )
26
+ COMMAND_NAME_RE = re.compile(r"<command-name>\s*([^<]*?)\s*</command-name>", re.I)
27
+ COMMAND_ARGS_RE = re.compile(r"<command-args>\s*([^<]*?)\s*</command-args>", re.I)
28
+
29
+
30
+ def blocks_of(message: Optional[dict]) -> List[dict]:
31
+ if not isinstance(message, dict):
32
+ return []
33
+ content = message.get("content")
34
+ if isinstance(content, str):
35
+ return [{"type": "text", "text": content}]
36
+ if isinstance(content, list):
37
+ return [b for b in content if isinstance(b, dict)]
38
+ return []
39
+
40
+
41
+ def result_text(content: object) -> str:
42
+ if isinstance(content, str):
43
+ return content
44
+ if isinstance(content, list):
45
+ parts = []
46
+ for b in content:
47
+ if not isinstance(b, dict):
48
+ continue
49
+ t = b.get("type")
50
+ if t == "text":
51
+ parts.append(str(b.get("text", "")))
52
+ elif t == "tool_reference":
53
+ parts.append(f"[tool reference: {b.get('tool_name', '?')}]")
54
+ elif t == "image":
55
+ parts.append("[image omitted]")
56
+ else:
57
+ parts.append(f"[unsupported block: {t}]")
58
+ return "\n".join(parts)
59
+ if content is None:
60
+ return ""
61
+ return str(content)
62
+
63
+
64
+ def clean_user_text(text: str) -> Tuple[str, Optional[str]]:
65
+ m = COMMAND_NAME_RE.search(text)
66
+ if m:
67
+ name = m.group(1).strip()
68
+ am = COMMAND_ARGS_RE.search(text)
69
+ args = am.group(1).strip() if am else ""
70
+ return "", (name + (" " + args if args else "")).strip()
71
+ return SYSTEM_NOISE_RE.sub("", text).strip(), None
72
+
73
+
74
+ def tool_label(name: str, inp: dict) -> str:
75
+ """Labels are built from full strings; the renderer redacts first and truncates last."""
76
+ if name == "Bash":
77
+ desc = str(inp.get("description") or "").strip()
78
+ cmd = str(inp.get("command") or "").strip().split("\n")[0]
79
+ return f"Bash: {desc or cmd}"
80
+ if name == "Read":
81
+ return f"Read: {home_to_tilde(str(inp.get('file_path', '')))}"
82
+ if name in EDIT_TOOLS:
83
+ return f"{name}: {home_to_tilde(str(inp.get('file_path') or inp.get('notebook_path') or ''))}"
84
+ if name in ("Agent", "Task"):
85
+ return f"Agent: {str(inp.get('description') or '')[:80]}"
86
+ if name.startswith("mcp__"):
87
+ parts = name.split("__")
88
+ return f"MCP {parts[1] if len(parts) > 1 else '?'}: {parts[-1]}"
89
+ if name in ("Grep", "Glob"):
90
+ return f"{name}: {str(inp.get('pattern', ''))[:80]}"
91
+ if name in ("WebFetch", "WebSearch"):
92
+ return f"{name}: {str(inp.get('url') or inp.get('query') or '')[:80]}"
93
+ return name
94
+
95
+
96
+ class ClaudeCodeAdapter(Adapter):
97
+ name = "claude-code"
98
+ label = "Claude Code"
99
+ env_ids = ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")
100
+ env_presence = ("CLAUDECODE",)
101
+ tested_version = (2, 1, 278)
102
+
103
+ def data_dir(self) -> str:
104
+ return os.path.join(config_dir(), "projects")
105
+
106
+ def locate_by_id(self, session_id: str) -> Optional[SessionRef]:
107
+ hits = glob.glob(os.path.join(self.data_dir(), "*", f"{session_id}.jsonl"))
108
+ if not hits:
109
+ return None
110
+ hits.sort(key=os.path.getmtime, reverse=True)
111
+ return self._ref(hits[0])
112
+
113
+ def ref_from_source(self, source: str) -> Optional[SessionRef]:
114
+ if source.endswith(".jsonl") and os.path.isfile(source):
115
+ return self._ref(os.path.abspath(source))
116
+ return None
117
+
118
+ def _ref(self, path: str) -> SessionRef:
119
+ sid = os.path.basename(path)[:-6]
120
+ first = first_json_line(path)
121
+ cwd = None
122
+ if first and isinstance(first.get("cwd"), str):
123
+ cwd = first["cwd"]
124
+ else:
125
+ # cwd lives on the first user/assistant record; peek a few lines
126
+ try:
127
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
128
+ for _ in range(40):
129
+ line = fh.readline()
130
+ if not line:
131
+ break
132
+ if '"cwd"' in line:
133
+ import json
134
+ try:
135
+ obj = json.loads(line)
136
+ if isinstance(obj.get("cwd"), str):
137
+ cwd = obj["cwd"]
138
+ break
139
+ except ValueError:
140
+ continue
141
+ except OSError:
142
+ pass
143
+ return SessionRef(self.name, sid, self.short_id(sid), path, cwd=cwd, updated=mtime(path))
144
+
145
+ def candidates(self, repo_root: str, limit: int = 5) -> List[SessionRef]:
146
+ paths = sorted(glob.glob(os.path.join(self.data_dir(), "*", "*.jsonl")), key=os.path.getmtime, reverse=True)[:80]
147
+ out: List[SessionRef] = []
148
+ for p in paths:
149
+ ref = self._ref(p)
150
+ if under(ref.cwd, repo_root):
151
+ out.append(ref)
152
+ if len(out) >= limit:
153
+ break
154
+ return out
155
+
156
+ # -- loading
157
+
158
+ def load(self, ref: SessionRef) -> Session:
159
+ records, bad = read_jsonl(ref.source)
160
+ by_uuid: Dict[str, dict] = {r["uuid"]: r for r in records if isinstance(r.get("uuid"), str)}
161
+ path = _active_path(records, by_uuid)
162
+ results: Dict[str, Tuple[str, bool]] = {}
163
+ for rec in path:
164
+ if rec.get("type") == "user":
165
+ for b in blocks_of(rec.get("message")):
166
+ if b.get("type") == "tool_result" and isinstance(b.get("tool_use_id"), str):
167
+ results[b["tool_use_id"]] = (result_text(b.get("content")), bool(b.get("is_error")))
168
+ events: List[Event] = []
169
+ for rec in path:
170
+ for ev in _events_from_record(rec, results, ref):
171
+ # the compaction summary follows its boundary; fold it into one event
172
+ if ev.kind == "compaction" and ev.meta.get("summary_only") and events and events[-1].kind == "compaction" and not events[-1].text:
173
+ events[-1].text = ev.text
174
+ continue
175
+ events.append(ev)
176
+ sidecars = self._subagent_files(ref)
177
+ for sub in sidecars:
178
+ sub_records, _ = read_jsonl(sub)
179
+ for rec in sub_records:
180
+ if rec.get("type") != "assistant":
181
+ continue
182
+ for b in blocks_of(rec.get("message")):
183
+ if b.get("type") == "tool_use" and b.get("name") in EDIT_TOOLS:
184
+ inp = b.get("input") if isinstance(b.get("input"), dict) else {}
185
+ p = inp.get("file_path") or inp.get("notebook_path")
186
+ if isinstance(p, str) and p:
187
+ events.append(Event("tool", tool=ToolCall("edit", str(b.get("name")), f"{b.get('name')}: {home_to_tilde(p)} (subagent)",
188
+ input={}, paths=[rel_to_root(p, _repo_hint(ref))]), meta={"subagent": True, "hidden": True}))
189
+ title = None
190
+ for rec in records:
191
+ if rec.get("type") == "custom-title" and isinstance(rec.get("customTitle"), str):
192
+ title = rec["customTitle"].strip()
193
+ if not title:
194
+ for rec in records:
195
+ if rec.get("type") == "ai-title" and isinstance(rec.get("aiTitle"), str):
196
+ title = rec["aiTitle"].strip()
197
+ stamps = [parse_ts(r.get("timestamp")) for r in records if r.get("type") in ("user", "assistant")]
198
+ stamps = [s for s in stamps if s]
199
+ version = next((r["version"] for r in records if isinstance(r.get("version"), str)), None)
200
+ visible = [e for e in events if not e.meta.get("hidden")]
201
+ hidden = [e for e in events if e.meta.get("hidden")]
202
+ return Session(
203
+ agent=self.name, session_id=ref.session_id, short_id=ref.short_id, title=title, cwd=ref.cwd,
204
+ started=min(stamps) if stamps else None, ended=max(stamps) if stamps else None,
205
+ events=visible + hidden, source=ref.source, agent_version=version, subagent_files=len(sidecars),
206
+ tested_version=self.tested_version, record_count=len(records), bad_lines=bad,
207
+ )
208
+
209
+ def _subagent_files(self, ref: SessionRef) -> List[str]:
210
+ side = os.path.join(os.path.dirname(ref.source), ref.session_id, "subagents")
211
+ return sorted(glob.glob(os.path.join(side, "*.jsonl")))
212
+
213
+
214
+ def _repo_hint(ref: SessionRef) -> Optional[str]:
215
+ return ref.extra.get("repo_root") or ref.cwd
216
+
217
+
218
+ def _active_path(records: List[dict], by_uuid: Dict[str, dict]) -> List[dict]:
219
+ leaf = None
220
+ for rec in reversed(records):
221
+ if rec.get("type") in ("user", "assistant") and not rec.get("isSidechain") and rec.get("uuid") in by_uuid:
222
+ leaf = rec
223
+ break
224
+ if leaf is None:
225
+ return [r for r in records if r.get("type") in ("user", "assistant", "system") and not r.get("isSidechain")]
226
+ path: List[dict] = []
227
+ seen = set()
228
+ cur: Optional[dict] = leaf
229
+ while cur is not None and cur.get("uuid") not in seen:
230
+ seen.add(cur.get("uuid"))
231
+ path.append(cur)
232
+ parent = cur.get("parentUuid")
233
+ if parent is None and cur.get("type") == "system" and cur.get("subtype") == "compact_boundary":
234
+ parent = cur.get("logicalParentUuid")
235
+ cur = by_uuid.get(parent) if isinstance(parent, str) else None
236
+ path.reverse()
237
+ return path
238
+
239
+
240
+ def _events_from_record(rec: dict, results: Dict[str, Tuple[str, bool]], ref: SessionRef) -> List[Event]:
241
+ rtype = rec.get("type")
242
+ ts = parse_ts(rec.get("timestamp"))
243
+ out: List[Event] = []
244
+ if rtype == "system":
245
+ if rec.get("subtype") == "compact_boundary":
246
+ meta = rec.get("compactMetadata") if isinstance(rec.get("compactMetadata"), dict) else {}
247
+ out.append(Event("compaction", ts=ts, meta={"pre_tokens": meta.get("preTokens"), "post_tokens": meta.get("postTokens")}))
248
+ return out
249
+ if rtype == "user":
250
+ if rec.get("isMeta") or rec.get("isSidechain"):
251
+ return out
252
+ if rec.get("isCompactSummary"):
253
+ # attach the summary text to the preceding compaction event when possible
254
+ out.append(Event("compaction", text=result_text(rec.get("message", {}).get("content")), ts=ts, meta={"summary_only": True}))
255
+ return out
256
+ for b in blocks_of(rec.get("message")):
257
+ btype = b.get("type")
258
+ if btype == "text":
259
+ raw = str(b.get("text", ""))
260
+ if PUSH_MARKER_RE.search(raw):
261
+ out.append(Event("user", text="", ts=ts, is_push_invocation=True))
262
+ continue
263
+ text, command = clean_user_text(raw)
264
+ if command:
265
+ out.append(Event("user", ts=ts, command=command))
266
+ elif text:
267
+ out.append(Event("user", text=text, ts=ts))
268
+ elif btype == "image":
269
+ out.append(Event("note", text="[image omitted]", ts=ts))
270
+ return out
271
+ if rtype == "assistant":
272
+ if rec.get("isSidechain"):
273
+ return out
274
+ if rec.get("isApiErrorMessage"):
275
+ out.append(Event("note", text="API error message omitted", ts=ts))
276
+ return out
277
+ for b in blocks_of(rec.get("message")):
278
+ btype = b.get("type")
279
+ if btype == "text":
280
+ out.append(Event("assistant", text=str(b.get("text", "")), ts=ts))
281
+ elif btype == "thinking":
282
+ out.append(Event("thinking", text=str(b.get("thinking", "")), ts=ts))
283
+ elif btype == "tool_use":
284
+ name = str(b.get("name") or "tool")
285
+ inp = b.get("input") if isinstance(b.get("input"), dict) else {}
286
+ kind = KIND_BY_NAME.get(name, "mcp" if name.startswith("mcp__") else "other")
287
+ paths: List[str] = []
288
+ if name in EDIT_TOOLS:
289
+ p = inp.get("file_path") or inp.get("notebook_path")
290
+ if isinstance(p, str) and p:
291
+ paths.append(rel_to_root(p, _repo_hint(ref)))
292
+ tool_id = b.get("id") if isinstance(b.get("id"), str) else None
293
+ output, is_error = results.get(tool_id, ("", False)) if tool_id else ("", False)
294
+ pending = bool(tool_id) and tool_id not in results
295
+ out.append(Event("tool", ts=ts, tool=ToolCall(kind, name, tool_label(name, inp), input=inp, output=output,
296
+ is_error=is_error, paths=paths, pending=pending)))
297
+ else:
298
+ out.append(Event("note", text=f"unsupported block: {btype}", ts=ts))
299
+ return out
300
+ return out