agentpace 0.9.2__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,449 @@
1
+ """The Cline adapter: one agent turn is one run, and one task is one batch. EXPERIMENTAL.
2
+
3
+ Built from the upstream source, not from real history, and shipped unverified as experimental.
4
+ Pinned commits of `cline/cline`: `94eecbbe1a2f899c66b118d13656651b0a884e1f` (main) and
5
+ `7761370240ffce9e3890cb56f6c51655f3f5b0a7` (the `legacy-extension` branch, whose writer the
6
+ stable VSIX still runs for most users, per `apps/vscode-rollout/README.md:6` and
7
+ `CHANGELOG.md:492` at main).
8
+
9
+ Layout (legacy writer, `apps/vscode/src/core/storage/disk.ts:129-133,328-339@7761370`):
10
+ `<storage>/tasks/<task id>/ui_messages.json`, one JSON array of messages, rewritten whole on every
11
+ change. `<storage>/state/taskHistory.json` (`disk.ts:454`) indexes the tasks. In VS Code,
12
+ `<storage>` is the extension's global storage folder (`extension.ts:645`),
13
+ `.../Code/User/globalStorage/saoudrizwan.claude-dev`. Tasks made by the SDK bundle ("Next") live
14
+ in `~/.cline/data/sessions` instead (`sdk/packages/shared/src/storage/paths.ts:194-200@94eecbb`)
15
+ and are not read here.
16
+
17
+ A message (`apps/vscode/src/shared/ExtensionMessage.ts:123-140@7761370`) is
18
+ `{ts, type: "ask"|"say", ask?, say?, text?, modelInfo?, ...}`. `ts` is epoch milliseconds, set
19
+ once when the message is created and never changed afterwards (`core/task/index.ts:837-903,
20
+ 859-863,1066-1078,3300`). An `ask` waits on the human (`index.ts:931-937`).
21
+
22
+ A run is one agent turn. It opens on the first message after the start of the task or after the
23
+ previous turn closed. It closes on the ask that hands the task back to the human: a
24
+ `completion_result`, `followup` or `plan_mode_respond` ask closes it as `completed`. A
25
+ `resume_task` or `resume_completed_task` ask means the task was left and reopened, so it closes
26
+ the open turn at the message before it, as `failed` when the turn's last request failed and
27
+ `unknown` otherwise. The same goes for a turn still open at the end of the file.
28
+
29
+ Approval waits are not agent time. Every other ask, apart from `command_output`, waits on a
30
+ human decision (a tool, command or MCP approval, a failed request to retry). The gap from that
31
+ ask to the next message is dropped whole, and the idle cap clips what remains.
32
+
33
+ Privacy: `_project` is the only code that touches a parsed message, and it copies an allowlist:
34
+ `ts`, `type`, the ask or say name from the fixed upstream set, `modelInfo.modelId`, two token
35
+ counters and a cancel reason from a fixed set out of an `api_req_started` text, and whether a
36
+ `tool` text names a file edit. Prompts, responses, reasoning, commands, tool arguments and
37
+ output, paths, images, files and the request text are never copied. `_read_index` copies only
38
+ each task's id and starting folder, a hash input that sits in memory and in the local cache as
39
+ Codex working folders do, never in a row. `_read_models` copies only model ids and times.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import os
46
+ import sys
47
+ from collections import Counter
48
+ from collections.abc import Iterable, Iterator
49
+ from dataclasses import dataclass, field
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ from agentpace.adapters.base import ParseStats, RawRun, Source
54
+ from agentpace.adapters.claude_code import _active, _as_int, _extended
55
+
56
+ # `ExtensionMessage.ts:142-160@7761370`.
57
+ _ASKS = frozenset(
58
+ {
59
+ "followup",
60
+ "plan_mode_respond",
61
+ "act_mode_respond",
62
+ "command",
63
+ "command_output",
64
+ "completion_result",
65
+ "tool",
66
+ "api_req_failed",
67
+ "resume_task",
68
+ "resume_completed_task",
69
+ "mistake_limit_reached",
70
+ "browser_action_launch",
71
+ "use_mcp_server",
72
+ "new_task",
73
+ "condense",
74
+ "summarize_task",
75
+ "report_bug",
76
+ "use_subagents",
77
+ }
78
+ )
79
+ # `ExtensionMessage.ts:162-200@7761370`.
80
+ _SAYS = frozenset(
81
+ {
82
+ "task",
83
+ "error",
84
+ "error_retry",
85
+ "api_req_started",
86
+ "api_req_finished",
87
+ "text",
88
+ "reasoning",
89
+ "completion_result",
90
+ "user_feedback",
91
+ "user_feedback_diff",
92
+ "api_req_retried",
93
+ "command",
94
+ "command_output",
95
+ "tool",
96
+ "shell_integration_warning",
97
+ "shell_integration_warning_with_suggestion",
98
+ "browser_action_launch",
99
+ "browser_action",
100
+ "browser_action_result",
101
+ "mcp_server_request_started",
102
+ "mcp_server_response",
103
+ "mcp_notification",
104
+ "use_mcp_server",
105
+ "diff_error",
106
+ "deleted_api_reqs",
107
+ "clineignore_error",
108
+ "command_permission_denied",
109
+ "checkpoint_created",
110
+ "load_mcp_documentation",
111
+ "generate_explanation",
112
+ "info",
113
+ "task_progress",
114
+ "hook_status",
115
+ "hook_output_stream",
116
+ "subagent",
117
+ "use_subagents",
118
+ "subagent_usage",
119
+ "conditional_rules_applied",
120
+ }
121
+ )
122
+ _COMPLETING_ASKS = frozenset({"completion_result", "followup", "plan_mode_respond"})
123
+ _RESUME_ASKS = frozenset({"resume_task", "resume_completed_task"})
124
+ # A command still running, not a human decision (`index.ts:907-912`).
125
+ _NOT_WAITS = frozenset({"command_output"})
126
+ # Each tool use leaves one message, a `say` when auto-approved and an `ask` otherwise
127
+ # (`WriteToFileToolHandler.ts:205-247`, `ExecuteCommandToolHandler.ts:83,231`,
128
+ # `UseMcpToolHandler.ts:42,96@7761370`).
129
+ _TOOL_NAMES = frozenset({"tool", "command", "use_mcp_server", "browser_action_launch"})
130
+ _EDIT_TOOLS = frozenset({"editedExistingFile", "newFileCreated", "fileDeleted"})
131
+ _ERROR_SAYS = frozenset({"error", "diff_error", "clineignore_error", "command_permission_denied"})
132
+ _ERROR_ASKS = frozenset({"api_req_failed", "mistake_limit_reached"})
133
+ # `ExtensionMessage.ts:376@7761370`. `user_cancelled` is an interruption, not a failure.
134
+ _CANCEL_REASONS = frozenset({"streaming_failed", "user_cancelled", "retries_exhausted"})
135
+ _FAILED_CANCELS = frozenset({"streaming_failed", "retries_exhausted"})
136
+ _MESSAGES = "ui_messages.json"
137
+ _METADATA = "task_metadata.json"
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class _Message:
142
+ time: float
143
+ kind: str # "ask" or "say"
144
+ name: str # one of `_ASKS` or `_SAYS`
145
+ model: str | None = None
146
+ tokens: int = 0
147
+ cancel: str | None = None
148
+ edit: bool = False
149
+
150
+ @property
151
+ def waits(self) -> bool:
152
+ """The gap after this message is a human decision, never agent time."""
153
+ return (
154
+ self.kind == "ask"
155
+ and self.name not in _NOT_WAITS
156
+ and self.name not in _COMPLETING_ASKS
157
+ and self.name not in _RESUME_ASKS
158
+ )
159
+
160
+
161
+ def _seconds(value: Any) -> float | None:
162
+ """Epoch milliseconds, as Cline writes them, in seconds. None for anything else."""
163
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
164
+ return value / 1000
165
+ return None
166
+
167
+
168
+ def _text_json(value: Any) -> dict[str, Any]:
169
+ """Decode a message's `text` when it holds a JSON object. Decoder messages are discarded,
170
+ because they can quote the text they failed on."""
171
+ if not isinstance(value, str):
172
+ return {}
173
+ try:
174
+ decoded = json.loads(value)
175
+ except (ValueError, RecursionError):
176
+ return {}
177
+ return decoded if isinstance(decoded, dict) else {}
178
+
179
+
180
+ def _project(record: Any) -> _Message | None:
181
+ """Copy allowed values out of one parsed message. The only code that touches a message.
182
+
183
+ None means malformed or unknown: the caller counts it and skips it.
184
+ """
185
+ if not isinstance(record, dict):
186
+ return None
187
+ time = _seconds(record.get("ts"))
188
+ if time is None:
189
+ return None
190
+ kind = record.get("type")
191
+ if kind == "ask":
192
+ name = record.get("ask")
193
+ known = _ASKS
194
+ elif kind == "say":
195
+ name = record.get("say")
196
+ known = _SAYS
197
+ else:
198
+ return None
199
+ if not isinstance(name, str) or name not in known:
200
+ return None
201
+ info = record.get("modelInfo")
202
+ model = info.get("modelId") if isinstance(info, dict) else None
203
+ tokens = 0
204
+ cancel = None
205
+ edit = False
206
+ if kind == "say" and name == "api_req_started":
207
+ # `ClineApiReqInfo`, `ExtensionMessage.ts:350-365`, written by `core/task/utils.ts:28-49`.
208
+ request = _text_json(record.get("text"))
209
+ tokens = _as_int(request.get("tokensIn")) + _as_int(request.get("tokensOut"))
210
+ reason = request.get("cancelReason")
211
+ cancel = reason if isinstance(reason, str) and reason in _CANCEL_REASONS else None
212
+ elif name == "tool":
213
+ # `ClineSayTool.tool`, `ExtensionMessage.ts:202-215`. Only a fixed name survives.
214
+ edit = _text_json(record.get("text")).get("tool") in _EDIT_TOOLS
215
+ return _Message(
216
+ time=time,
217
+ kind=kind,
218
+ name=name,
219
+ model=model if isinstance(model, str) and model else None,
220
+ tokens=tokens,
221
+ cancel=cancel,
222
+ edit=edit,
223
+ )
224
+
225
+
226
+ @dataclass
227
+ class _Turn:
228
+ messages: list[_Message] = field(default_factory=list)
229
+ failing: bool = False # the latest request failed and nothing has succeeded since
230
+ outcome: str = "unknown"
231
+
232
+ def add(self, message: _Message) -> None:
233
+ self.messages.append(message)
234
+ if message.name == "api_req_started":
235
+ self.failing = message.cancel in _FAILED_CANCELS
236
+ elif message.kind == "ask" and message.name in _ERROR_ASKS:
237
+ self.failing = True
238
+
239
+ def left_open(self) -> _Turn:
240
+ self.outcome = "failed" if self.failing else "unknown"
241
+ return self
242
+
243
+
244
+ def _turns(messages: Iterable[_Message]) -> Iterator[_Turn]:
245
+ """Group messages into turns by position, closing each on the ask that hands back."""
246
+ turn: _Turn | None = None
247
+ for message in messages:
248
+ resume = message.kind == "ask" and message.name in _RESUME_ASKS
249
+ completing = message.kind == "ask" and message.name in _COMPLETING_ASKS
250
+ if resume:
251
+ if turn is not None:
252
+ yield turn.left_open() # the turn ends at the message before the reopening
253
+ turn = None
254
+ continue
255
+ if turn is None:
256
+ if completing:
257
+ continue # nothing open to close
258
+ turn = _Turn()
259
+ turn.add(message)
260
+ if completing:
261
+ turn.outcome = "completed"
262
+ yield turn
263
+ turn = None
264
+ if turn is not None:
265
+ yield turn.left_open()
266
+
267
+
268
+ def _timing(
269
+ messages: list[_Message], idle_cap_s: int
270
+ ) -> tuple[int, tuple[tuple[float, float], ...]]:
271
+ """Active seconds and spans, with every approval wait dropped and other gaps capped."""
272
+ segments: list[list[float]] = [[]]
273
+ for message in messages:
274
+ segments[-1].append(message.time)
275
+ if message.waits:
276
+ segments.append([])
277
+ active = 0
278
+ spans: list[tuple[float, float]] = []
279
+ for segment in segments:
280
+ if not segment:
281
+ continue
282
+ seconds, parts = _active(sorted(segment), idle_cap_s)
283
+ active += seconds
284
+ spans.extend(parts)
285
+ return active, tuple(sorted(spans))
286
+
287
+
288
+ def _read_json(path: str) -> Any:
289
+ """A whole JSON file, or None. Error messages are discarded, since they can quote content."""
290
+ try:
291
+ with open(path, encoding="utf-8", errors="replace") as handle:
292
+ return json.load(handle)
293
+ except (OSError, ValueError, RecursionError):
294
+ return None
295
+
296
+
297
+ def _read_index(path: str) -> dict[str, str]:
298
+ """Task id to starting folder, from `state/taskHistory.json` (`HistoryItem.ts:2,14@7761370`).
299
+
300
+ Each entry also holds the task's prompt and costs. Only `id` and `cwdOnTaskInitialization`
301
+ are copied.
302
+ """
303
+ folders: dict[str, str] = {}
304
+ items = _read_json(path)
305
+ for item in items if isinstance(items, list) else ():
306
+ if not isinstance(item, dict):
307
+ continue
308
+ task_id = item.get("id")
309
+ folder = item.get("cwdOnTaskInitialization")
310
+ if isinstance(task_id, str) and isinstance(folder, str):
311
+ folders[task_id] = folder
312
+ return folders
313
+
314
+
315
+ def _read_models(path: str) -> list[tuple[float, str]]:
316
+ """`(time, model id)` pairs from `task_metadata.json` `model_usage`
317
+ (`ContextTrackerTypes.ts:11-32`, written by `ModelContextTracker.ts:28-33@7761370`).
318
+ File paths, environment details and provider ids in the same file are never copied."""
319
+ meta = _read_json(path)
320
+ usage = meta.get("model_usage") if isinstance(meta, dict) else None
321
+ models: list[tuple[float, str]] = []
322
+ for entry in usage if isinstance(usage, list) else ():
323
+ if not isinstance(entry, dict):
324
+ continue
325
+ time = _seconds(entry.get("ts"))
326
+ model = entry.get("model_id")
327
+ if time is not None and isinstance(model, str) and model:
328
+ models.append((time, model))
329
+ return sorted(models)
330
+
331
+
332
+ def _fallback_model(models: list[tuple[float, str]], start: float) -> str | None:
333
+ """The model in use when the turn started, else the first one recorded."""
334
+ chosen = None
335
+ for time, model in models:
336
+ if time <= start or chosen is None:
337
+ chosen = model
338
+ return chosen
339
+
340
+
341
+ def _vscode_user_dir() -> Path:
342
+ """VS Code's per-user folder. This is VS Code's convention, not Cline's, and is unverified."""
343
+ if sys.platform == "win32":
344
+ appdata = os.environ.get("APPDATA")
345
+ return (Path(appdata) if appdata else Path.home() / "AppData" / "Roaming") / "Code" / "User"
346
+ if sys.platform == "darwin":
347
+ return Path.home() / "Library" / "Application Support" / "Code" / "User"
348
+ config = os.environ.get("XDG_CONFIG_HOME")
349
+ return (Path(config) if config else Path.home() / ".config") / "Code" / "User"
350
+
351
+
352
+ class ClineAdapter:
353
+ name = "cline"
354
+ # Bump whenever `parse` would return something different for the same file, so cached
355
+ # runs from the old logic are discarded.
356
+ version = 1
357
+
358
+ def default_path(self) -> Path:
359
+ # `<publisher>.<name>` from `apps/vscode/package.json:2,14@94eecbb`.
360
+ return _vscode_user_dir() / "globalStorage" / "saoudrizwan.claude-dev"
361
+
362
+ def discover(self, path: Path) -> Iterator[Source]:
363
+ root = _extended(path)
364
+ listing: Iterable[tuple[str, list[str], list[str]]]
365
+ if os.path.isfile(root):
366
+ listing = [(os.path.dirname(root), [], [os.path.basename(root)])]
367
+ else:
368
+ listing = os.walk(root)
369
+ indexes: dict[str, dict[str, str]] = {}
370
+ for directory, _, files in listing:
371
+ if _MESSAGES not in files:
372
+ continue
373
+ tasks = os.path.dirname(directory)
374
+ if os.path.basename(tasks) != "tasks":
375
+ continue
376
+ storage = os.path.dirname(tasks)
377
+ if storage not in indexes:
378
+ indexes[storage] = _read_index(os.path.join(storage, "state", "taskHistory.json"))
379
+ task_id = os.path.basename(directory)
380
+ yield Source(
381
+ path=os.path.join(directory, _MESSAGES),
382
+ session=task_id,
383
+ # Missing from the index means one shared project pseudonym, never a guess.
384
+ project=indexes[storage].get(task_id, ""),
385
+ companions=(os.path.join(directory, _METADATA),),
386
+ )
387
+
388
+ def parse(self, source: Source, idle_cap_s: int, stats: ParseStats) -> list[RawRun]:
389
+ try:
390
+ with open(source.path, encoding="utf-8", errors="replace") as handle:
391
+ data = json.load(handle)
392
+ except OSError:
393
+ stats.unreadable_files += 1
394
+ return []
395
+ except (ValueError, RecursionError):
396
+ stats.unparsed_lines += 1 # the whole file is one JSON array
397
+ return []
398
+ if not isinstance(data, list):
399
+ stats.unparsed_lines += 1
400
+ return []
401
+ messages: list[_Message] = []
402
+ seen: set[_Message] = set()
403
+ for record in data:
404
+ message = _project(record)
405
+ if message is None:
406
+ stats.unparsed_lines += 1
407
+ continue
408
+ if message in seen:
409
+ continue # a repeated message is one message
410
+ seen.add(message)
411
+ messages.append(message)
412
+ last_model = next((m.model for m in reversed(messages) if m.model), None)
413
+ recorded: list[tuple[float, str]] | None = None
414
+ runs: list[RawRun] = []
415
+ for turn in _turns(messages):
416
+ times = [message.time for message in turn.messages]
417
+ models = Counter(message.model for message in turn.messages if message.model)
418
+ model = models.most_common(1)[0][0] if models else last_model
419
+ if model is None:
420
+ if recorded is None:
421
+ recorded = _read_models(source.companions[0]) if source.companions else []
422
+ model = _fallback_model(recorded, min(times))
423
+ if model is None or len(times) < 2:
424
+ stats.empty_runs += 1
425
+ continue
426
+ active_s, spans = _timing(turn.messages, idle_cap_s)
427
+ tokens = sum(message.tokens for message in turn.messages)
428
+ runs.append(
429
+ RawRun(
430
+ harness=self.name,
431
+ model=model,
432
+ start=min(times),
433
+ end=max(times),
434
+ spans=spans,
435
+ active_s=active_s,
436
+ tool_calls=sum(m.name in _TOOL_NAMES for m in turn.messages),
437
+ edit_calls=sum(m.edit for m in turn.messages),
438
+ tokens=tokens or None,
439
+ errors=sum(
440
+ (m.kind == "say" and m.name in _ERROR_SAYS)
441
+ or (m.kind == "ask" and m.name in _ERROR_ASKS)
442
+ for m in turn.messages
443
+ ),
444
+ outcome=turn.outcome,
445
+ session=source.session,
446
+ project=source.project,
447
+ )
448
+ )
449
+ return runs