pcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/sessions.py ADDED
@@ -0,0 +1,892 @@
1
+ """Private session metadata/journal around Harness's native step persistence."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import sqlite3
8
+ import tempfile
9
+ from copy import deepcopy
10
+ from dataclasses import asdict, dataclass, field, replace
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Literal
14
+ from uuid import uuid4
15
+
16
+ from filelock import FileLock, Timeout
17
+ from pydantic import BaseModel
18
+ from pydantic_ai_harness.step_persistence import SqliteStepStore, StepEvent, ToolEffectRecord
19
+
20
+ from pcode.conversation_tree import ConversationTree
21
+ from pcode.diagnostics import error_report, redact, versions
22
+
23
+
24
+ class SessionError(ValueError):
25
+ pass
26
+
27
+
28
+ class SessionBusy(SessionError):
29
+ """Another process holds the session's lock."""
30
+
31
+
32
+ class SessionInfo(BaseModel):
33
+ version: Literal[1] = 1
34
+ id: str
35
+ model: str
36
+ workspace: str
37
+ # Preserve project scope even after a linked worktree has been removed.
38
+ project: str | None = None
39
+ # HEAD when the session began, so /diffs still shows work once it is committed.
40
+ start_commit: str | None = None
41
+ created: str
42
+ updated: str
43
+ status: str = "new"
44
+ # Set with /rename; /resume lists and searches it.
45
+ name: str | None = None
46
+ # Asked of the model beside the first turn (`session_naming`); `name` wins.
47
+ title: str | None = None
48
+ turns: int = 0
49
+ input_tokens: int = 0
50
+ output_tokens: int = 0
51
+ # Defaulted, so a session written before cache accounting still loads.
52
+ cache_read_tokens: int = 0
53
+ cache_write_tokens: int = 0
54
+ packages: dict[str, str]
55
+
56
+
57
+ def now() -> str:
58
+ return datetime.now(timezone.utc).isoformat()
59
+
60
+
61
+ def session_root() -> Path:
62
+ if value := os.environ.get("PCODE_SESSION_DIR"):
63
+ return Path(value).expanduser()
64
+ state = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
65
+ return state / "pcode" / "sessions"
66
+
67
+
68
+ def private_file(path: Path) -> None:
69
+ fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0), 0o600)
70
+ try:
71
+ os.fchmod(fd, 0o600)
72
+ finally:
73
+ os.close(fd)
74
+
75
+
76
+ def read_info(path: Path) -> SessionInfo:
77
+ try:
78
+ info = SessionInfo.model_validate_json((path / "session.json").read_text())
79
+ except (OSError, ValueError):
80
+ raise SessionError(
81
+ "Session metadata is unreadable or uses an unsupported format."
82
+ ) from None
83
+ if info.id != path.name:
84
+ raise SessionError("Session ID does not match its directory.")
85
+ return info
86
+
87
+
88
+ def list_sessions(root: Path | None = None) -> list[SessionInfo]:
89
+ root = root or session_root()
90
+ result = []
91
+ if root.is_dir():
92
+ for path in root.iterdir():
93
+ if path.is_dir() and not path.is_symlink():
94
+ try:
95
+ result.append(read_info(path))
96
+ except SessionError:
97
+ continue
98
+ return sorted(result, key=lambda info: info.updated, reverse=True)
99
+
100
+
101
+ def is_open(directory: Path) -> bool:
102
+ """Whether a process holds this session, probed without keeping the lock."""
103
+ lock = FileLock(directory / ".lock", mode=0o600)
104
+ try:
105
+ lock.acquire(timeout=0)
106
+ except Timeout:
107
+ return True
108
+ except OSError:
109
+ return False # Deleted since it was listed; nothing can hold it now.
110
+ lock.release()
111
+ return False
112
+
113
+
114
+ def sharing(
115
+ workspace: Path, root: Path | None = None, exclude: str | None = None
116
+ ) -> list[tuple[str, bool]]:
117
+ """Other sessions still using `workspace`, as (ID, open) pairs, excluding `exclude`.
118
+
119
+ A copied session (`--fork`, or one whose original was busy) shares that
120
+ original's directory, so tidying a worktree on the way out has to ask this
121
+ first. One counts while it is open or has a finished turn to resume.
122
+ """
123
+ root = root or session_root()
124
+ home = str(workspace.resolve())
125
+ found = []
126
+ for info in list_sessions(root):
127
+ if info.workspace != home or info.id == exclude:
128
+ continue
129
+ running = is_open(root / info.id)
130
+ if running or info.turns:
131
+ found.append((info.id, running))
132
+ return found
133
+
134
+
135
+ def delete_session(identity: str, root: Path | None = None) -> None:
136
+ """Remove a saved session's directory; refuses one open in any process."""
137
+ root = root or session_root()
138
+ directory = root / identity
139
+ if directory.is_symlink() or not directory.is_dir() or directory.parent != root:
140
+ raise SessionError("No such session.")
141
+ lock = FileLock(directory / ".lock", mode=0o600)
142
+ try:
143
+ lock.acquire(timeout=0)
144
+ except Timeout:
145
+ raise SessionError("This session is open in another process.") from None
146
+ try:
147
+ try:
148
+ info = read_info(directory)
149
+ except SessionError:
150
+ info = None
151
+ shutil.rmtree(directory)
152
+ finally:
153
+ lock.release()
154
+ if info is not None:
155
+ # By the directory just removed: its refs are keyed by that id.
156
+ forget_refs(info.model_copy(update={"id": identity}))
157
+
158
+
159
+ def forget_refs(info: SessionInfo) -> None:
160
+ """Drop the git refs a session kept (its /diffs review checkpoint).
161
+
162
+ Best effort: the repository may be gone, and a leftover ref only holds
163
+ objects git would otherwise collect.
164
+ """
165
+ from pcode.git_diff import GitDiffError, forget_session
166
+ from pcode.worktree import session_scope
167
+
168
+ try:
169
+ scope = session_scope(info)
170
+ if scope.is_dir():
171
+ forget_session(scope, info.id)
172
+ except (GitDiffError, OSError):
173
+ pass
174
+
175
+
176
+ @dataclass
177
+ class ToolCall:
178
+ """One settled tool call, summarized the way scrollback summarized it.
179
+
180
+ The transcript's own `result` is deliberately left behind: it is most of a
181
+ transcript's bulk (hundreds of KB where the summaries are tens), and the
182
+ tool inspector is where a full result belongs.
183
+ """
184
+
185
+ name: str
186
+ detail: str = ""
187
+ failed: bool = False
188
+ elapsed_seconds: float | None = None
189
+ command: str = ""
190
+
191
+
192
+ @dataclass
193
+ class Turn:
194
+ """One prompt and the final response it produced, for browsing and search."""
195
+
196
+ prompt: str
197
+ response: str = ""
198
+ status: str = "running" # "complete", "cancelled", "failed", or still "running".
199
+ # Text blocks and settled tool calls in transcript order, so a browsed turn
200
+ # can be replayed in the order it happened. `response` stays the last text
201
+ # block: the session list and the conversation tree want that one alone.
202
+ blocks: list["str | ToolCall"] = field(default_factory=list)
203
+
204
+
205
+ _TURN_KINDS = (
206
+ "turn_started",
207
+ "Message",
208
+ "ToolSummary",
209
+ "turn_completed",
210
+ "turn_cancelled",
211
+ "turn_failed",
212
+ )
213
+
214
+
215
+ @dataclass
216
+ class SessionReadBudget:
217
+ """A shared byte budget for readers that can report partial coverage."""
218
+
219
+ remaining: int
220
+ exhausted: bool = False
221
+
222
+
223
+ def session_records(
224
+ info: SessionInfo,
225
+ root: Path | None = None,
226
+ *,
227
+ kinds: tuple[str, ...] = _TURN_KINDS,
228
+ budget: SessionReadBudget | None = None,
229
+ ):
230
+ """Yield turn-level records without opening or locking the session.
231
+
232
+ Raises ``OSError`` when the transcript cannot be read. Transcripts are
233
+ mostly streaming deltas, so only candidate lines are parsed.
234
+ """
235
+ directory = (root or session_root()) / info.id
236
+ path = directory / "transcript.jsonl"
237
+ if directory.is_symlink() or path.is_symlink():
238
+ raise OSError("symlinked session")
239
+ markers = tuple(f'"{kind}"'.encode() for kind in kinds)
240
+ with path.open("rb") as stream:
241
+ while line := stream.readline(budget.remaining + 1 if budget else -1):
242
+ if budget:
243
+ if len(line) > budget.remaining:
244
+ budget.exhausted = True
245
+ return
246
+ budget.remaining -= len(line)
247
+ if not any(marker in line for marker in markers):
248
+ continue
249
+ try:
250
+ record = json.loads(line.decode("utf-8", errors="replace"))
251
+ except ValueError:
252
+ continue
253
+ if isinstance(record, dict) and record.get("kind") in kinds:
254
+ yield record
255
+
256
+
257
+ def session_turns(info: SessionInfo, root: Path | None = None) -> list[Turn] | None:
258
+ """Read every turn in file order; ``None`` when the transcript is unreadable.
259
+
260
+ Forked branches are included: reconstructing the active path needs the
261
+ session lock, which the live session holds, and abandoned prompts are
262
+ still worth finding.
263
+ """
264
+ turns: list[Turn] = []
265
+ try:
266
+ for record in session_records(info, root):
267
+ kind = record["kind"]
268
+ if kind == "turn_started":
269
+ prompt = record.get("prompt")
270
+ if not isinstance(prompt, str):
271
+ continue
272
+ # A /resend retry reuses the prompt; its outcome belongs to the same turn.
273
+ if record.get("continuation") and turns and turns[-1].prompt == prompt:
274
+ turns[-1].status = "running"
275
+ continue
276
+ turns.append(Turn(prompt))
277
+ elif not turns:
278
+ continue
279
+ elif kind == "Message":
280
+ markdown = record.get("markdown")
281
+ if isinstance(markdown, str):
282
+ turns[-1].response = markdown
283
+ turns[-1].blocks.append(markdown)
284
+ elif kind == "ToolSummary":
285
+ elapsed = record.get("elapsed_seconds")
286
+ turns[-1].blocks.append(
287
+ ToolCall(
288
+ str(record.get("name") or ""),
289
+ str(record.get("detail") or ""),
290
+ bool(record.get("failed")),
291
+ elapsed if isinstance(elapsed, (int, float)) else None,
292
+ str(record.get("command") or ""),
293
+ )
294
+ )
295
+ elif kind == "turn_completed":
296
+ turns[-1].status = "complete"
297
+ elif kind == "turn_cancelled":
298
+ turns[-1].status = "cancelled"
299
+ elif kind == "turn_failed":
300
+ turns[-1].status = "failed"
301
+ except OSError:
302
+ return None
303
+ return turns
304
+
305
+
306
+ def first_prompt(info: SessionInfo, root: Path | None = None) -> str:
307
+ """The first submitted prompt, stopping at the first record so listing stays cheap."""
308
+ try:
309
+ for record in session_records(info, root):
310
+ if record["kind"] == "turn_started" and isinstance(record.get("prompt"), str):
311
+ return record["prompt"]
312
+ except OSError:
313
+ return "(Prompt unavailable)"
314
+ return "(No prompt yet)"
315
+
316
+
317
+ SNAPSHOTS_PER_RUN = 2
318
+ """How many step checkpoints a turn keeps.
319
+
320
+ Harness saves the whole message history again at every settled step, so a turn
321
+ with 300 tool calls stores 300 copies of itself and a session reaches gigabytes.
322
+ Nothing here reads them: `history_at` and `recover` both take the newest
323
+ snapshot of a run, and a compaction node restores from the conversation tree
324
+ instead. The bound keeps the newest two, and Harness widens that retain set to
325
+ cover the newest `complete` one when the newest is interrupted, so a crash mid
326
+ turn still continues from its last settled step.
327
+
328
+ What it gives up is rewinding *inside* a turn, which no command offers, and
329
+ pre-compaction history for a run, which `history_at` takes from the tree.
330
+ """
331
+
332
+
333
+ def _store_bytes(database: Path) -> int:
334
+ """The store's size including its write-ahead log, where recent writes still live."""
335
+ names = (database.name, database.name + "-wal", database.name + "-shm")
336
+ return sum(
337
+ (database.parent / name).stat().st_size
338
+ for name in names
339
+ if (database.parent / name).is_file()
340
+ )
341
+
342
+
343
+ def compact_snapshots(directory: Path, keep: int = SNAPSHOTS_PER_RUN) -> tuple[int, int]:
344
+ """Apply the retain bound to one existing session, returning its size before and after.
345
+
346
+ Sessions written before the bound existed keep every step checkpoint, and
347
+ deleting rows alone frees pages for reuse without returning them to the
348
+ filesystem, so this vacuums as well. Both steps are fast because the live
349
+ data is what remains: about a second for the largest session observed.
350
+
351
+ A session open elsewhere is skipped (its size reported unchanged) rather
352
+ than compacted underneath the process still writing to it.
353
+ """
354
+ database = directory / "steps.sqlite3"
355
+ if not database.is_file():
356
+ return 0, 0
357
+ before = _store_bytes(database)
358
+ lock = FileLock(directory / ".lock", mode=0o600)
359
+ try:
360
+ lock.acquire(timeout=0)
361
+ except Timeout:
362
+ return before, before
363
+ try:
364
+ connection = sqlite3.connect(database)
365
+ try:
366
+ # Mirrors Harness's retain set: the newest `keep` of each run, plus
367
+ # each run's newest settled snapshot, which is the one every read
368
+ # here takes. A row predating the state column counts as complete.
369
+ connection.execute(
370
+ "DELETE FROM snapshots WHERE seq NOT IN ("
371
+ " SELECT seq FROM ("
372
+ " SELECT seq, row_number() OVER ("
373
+ " PARTITION BY run_id ORDER BY seq DESC) AS rank FROM snapshots"
374
+ " ) WHERE rank <= ?"
375
+ " UNION SELECT max(seq) FROM snapshots"
376
+ " WHERE coalesce(state, 'complete') = 'complete' GROUP BY run_id)",
377
+ (keep,),
378
+ )
379
+ connection.commit()
380
+ connection.execute("VACUUM")
381
+ connection.commit()
382
+ # A vacuum in WAL mode rewrites the store into the log; the file on
383
+ # disk only shrinks once that log is folded back and truncated.
384
+ connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
385
+ finally:
386
+ connection.close()
387
+ except sqlite3.Error:
388
+ # A session whose store cannot be rewritten is left exactly as it was.
389
+ return before, _store_bytes(database)
390
+ finally:
391
+ lock.release()
392
+ return before, _store_bytes(database)
393
+
394
+
395
+ def is_session_selector(value: str) -> bool:
396
+ """Tell an ID/prefix apart from prompt text after an optional-argument flag."""
397
+ return value == "latest" or bool(re.fullmatch(r"[a-f0-9-]{8,36}", value))
398
+
399
+
400
+ def resolve_session(selector: str, root: Path | None = None, workspace: Path | None = None) -> Path:
401
+ """Resolve an ID/prefix, or the newest session, optionally scoped to a workspace."""
402
+ root = root or session_root()
403
+ if not is_session_selector(selector):
404
+ raise SessionError("Use a session ID from --sessions, or omit it for the latest.")
405
+ sessions = list_sessions(root)
406
+ if selector != "latest":
407
+ matches = [s for s in sessions if s.id.startswith(selector)]
408
+ elif workspace is None:
409
+ matches = sessions[:1]
410
+ else:
411
+ # "Latest" means this checkout's newest session, never another repo's.
412
+ home = str(workspace.resolve())
413
+ matches = [s for s in sessions if s.workspace == home][:1]
414
+ if not matches:
415
+ raise SessionError(f"No saved session for {home}; use --sessions to pick one.")
416
+ if len(matches) != 1:
417
+ raise SessionError("Session not found or prefix is ambiguous; use --sessions.")
418
+ return root / matches[0].id
419
+
420
+
421
+ class PrivateStepStore(SqliteStepStore):
422
+ """Use the public store hooks to redact error strings, not replay-critical history."""
423
+
424
+ async def append_event(self, event: StepEvent) -> None:
425
+ if event.error:
426
+ event = replace(event, error=redact(event.error))
427
+ await super().append_event(event)
428
+
429
+ async def record_tool_effect(self, record: ToolEffectRecord) -> None:
430
+ if record.effect_summary:
431
+ record = replace(record, effect_summary=redact(record.effect_summary))
432
+ await super().record_tool_effect(record)
433
+
434
+
435
+ def _parse(line: bytes) -> dict | None:
436
+ """One journal line as a record; anything else (torn, foreign) is skipped."""
437
+ try:
438
+ record = json.loads(line.decode("utf-8", errors="replace"))
439
+ except ValueError:
440
+ return None
441
+ return record if isinstance(record, dict) else None
442
+
443
+
444
+ class SessionJournal:
445
+ """A session's conversation, readable without owning it.
446
+
447
+ `read` never locks or writes, so a terminal can display a session that a
448
+ host process holds open and keeps appending to; `refresh` follows it.
449
+ """
450
+
451
+ def __init__(self, directory: Path, info: SessionInfo) -> None:
452
+ if directory.is_symlink():
453
+ raise SessionError("Refusing a symlinked session directory.")
454
+ self.directory = directory
455
+ self.info = info
456
+ # The session this one was copied from because that one was open.
457
+ self.forked_from: str | None = None
458
+ # True when the copy was asked for (`--fork`) rather than forced by a lock.
459
+ self.fork_requested = False
460
+ self.tree = ConversationTree()
461
+ # End of the last complete journal line fed to `tree`, and which file it
462
+ # was in, so a replaced journal is rebuilt rather than read mid-record.
463
+ self._offset = 0
464
+ self._journal_id: tuple[int, int] | None = None
465
+
466
+ @classmethod
467
+ def read(cls, directory: Path) -> "SessionJournal":
468
+ """Open a session read-only, even while another process holds and writes it."""
469
+ if directory.is_symlink():
470
+ raise SessionError("Refusing a symlinked session directory.")
471
+ journal = cls(directory, read_info(directory))
472
+ journal._follow()
473
+ return journal
474
+
475
+ def refresh(self) -> bool:
476
+ """Catch up with what the owner has written since; whether anything changed."""
477
+ changed = self._follow()
478
+ info = read_info(self.directory)
479
+ if info != self.info:
480
+ self.info = info
481
+ changed = True
482
+ return changed
483
+
484
+ def _follow(self) -> bool:
485
+ """Feed `tree` the complete lines appended since the last call.
486
+
487
+ A final line without its newline may still be being written, so it is
488
+ left for the next call to read whole.
489
+ """
490
+ path = self.directory / "transcript.jsonl"
491
+ try:
492
+ file = path.open("rb")
493
+ except FileNotFoundError:
494
+ return False
495
+ with file:
496
+ stat = os.fstat(file.fileno())
497
+ identity = (stat.st_dev, stat.st_ino)
498
+ changed = False
499
+ if identity != self._journal_id or stat.st_size < self._offset:
500
+ changed = self._offset > 0 or self._journal_id is not None
501
+ self.tree = ConversationTree()
502
+ self._offset = 0
503
+ self._journal_id = identity
504
+ file.seek(self._offset)
505
+ for line in file:
506
+ if not line.endswith(b"\n"):
507
+ break
508
+ self._offset += len(line)
509
+ changed = True
510
+ if (record := _parse(line)) is not None:
511
+ self.tree.consume(record)
512
+ return changed
513
+
514
+ def journal_size(self) -> int:
515
+ """Where the next record will start; `end` for records written before now."""
516
+ return (self.directory / "transcript.jsonl").stat().st_size
517
+
518
+ def records(self, end: int | None = None):
519
+ """Read intact records; a hard kill may leave a torn final append.
520
+
521
+ `end` stops at a `journal_size()` taken earlier, so a turn still being
522
+ written can be left out of what is read.
523
+ """
524
+ with (self.directory / "transcript.jsonl").open("rb") as file:
525
+ consumed = 0
526
+ for line in file:
527
+ consumed += len(line)
528
+ if end is not None and consumed > end:
529
+ return
530
+ if (record := _parse(line)) is not None:
531
+ yield record
532
+
533
+ def active_records(self, end: int | None = None):
534
+ """Records on the selected branch, by the turn each one names.
535
+
536
+ `recording` is the pre-run-id fallback: in an older journal a record
537
+ belongs to the last turn started before it, which is only true because
538
+ those sessions could never have two turns open at once.
539
+ """
540
+ selected = set(self.tree.path(self.tree.active))
541
+ recording = None
542
+ for record in self.records(end):
543
+ if record.get("kind") == "turn_started":
544
+ recording = record.get("run_id")
545
+ if not self.tree.nodes or (record.get("run_id") or recording) in selected:
546
+ yield record
547
+
548
+ def latest_plan(self) -> list[dict]:
549
+ """Recover UI/tool state even when the last update predates replay's limit."""
550
+ if self.tree.nodes:
551
+ return deepcopy(self.tree.nodes[self.tree.active].plan) if self.tree.active else []
552
+ items = []
553
+ for record in self.active_records():
554
+ if record.get("kind") == "PlanUpdated":
555
+ items = record["items"]
556
+ return items
557
+
558
+ def tool_events(self):
559
+ """Stream tool lifecycle records independently of the transcript replay limit."""
560
+ for record in self.active_records():
561
+ if record.get("kind") in {
562
+ "ToolStarted",
563
+ "ToolSummary",
564
+ "turn_started",
565
+ "turn_completed",
566
+ "turn_cancelled",
567
+ "turn_failed",
568
+ }:
569
+ yield record
570
+
571
+ def transcript_records(self, end: int | None = None):
572
+ """Stream active-path display records; Transcript owns the retention budget.
573
+
574
+ Only unfinished text is buffered. Flush thinking before interleaved display
575
+ records, as live output does, and don't repeat it at the completion marker.
576
+ """
577
+ partial = ""
578
+ thinking = ""
579
+ thinking_streamed = False
580
+ for record in self.active_records(end):
581
+ kind = record.get("kind")
582
+ if kind == "ThinkingDelta":
583
+ thinking += record["text"]
584
+ thinking_streamed = True
585
+ continue
586
+ if kind not in {
587
+ "Thinking",
588
+ "TextDelta",
589
+ "Message",
590
+ "ToolSummary",
591
+ "JobFinished",
592
+ "EditCompleted",
593
+ "CacheBust",
594
+ "steering",
595
+ "turn_started",
596
+ "turn_completed",
597
+ "turn_failed",
598
+ "turn_cancelled",
599
+ }:
600
+ continue
601
+ if thinking:
602
+ yield {"kind": "thinking_partial", "text": thinking}
603
+ thinking = ""
604
+ if kind == "Thinking":
605
+ if not thinking_streamed:
606
+ yield record
607
+ thinking_streamed = False
608
+ elif kind == "TextDelta":
609
+ thinking_streamed = False
610
+ partial += record["text"]
611
+ elif kind == "Message":
612
+ thinking_streamed = False
613
+ partial = ""
614
+ yield record
615
+ elif kind.startswith("turn_"):
616
+ thinking_streamed = False
617
+ if partial:
618
+ yield {"kind": "partial", "markdown": partial}
619
+ partial = ""
620
+ yield record
621
+ else:
622
+ yield record
623
+ if thinking:
624
+ yield {"kind": "thinking_partial", "text": thinking}
625
+ if partial:
626
+ yield {"kind": "partial", "markdown": partial}
627
+
628
+
629
+ class SavedSession(SessionJournal):
630
+ """The one process that owns a session: holds its lock and writes it."""
631
+
632
+ def __init__(self, directory: Path, info: SessionInfo) -> None:
633
+ super().__init__(directory, info)
634
+ directory.chmod(0o700)
635
+ self.lock = FileLock(directory / ".lock", mode=0o600)
636
+ try:
637
+ self.lock.acquire(timeout=0)
638
+ except Timeout:
639
+ raise SessionBusy("This session is already open in another process.") from None
640
+ try:
641
+ for name in ("steps.sqlite3", "transcript.jsonl", "session.json"):
642
+ private_file(directory / name)
643
+ self.store = PrivateStepStore(
644
+ database=directory / "steps.sqlite3",
645
+ max_snapshots_per_run=SNAPSHOTS_PER_RUN,
646
+ )
647
+ for record in self.records():
648
+ self.tree.consume(record)
649
+ except BaseException:
650
+ self.lock.release()
651
+ raise
652
+
653
+ @classmethod
654
+ def create(
655
+ cls, model: str, workspace: Path, root: Path | None = None, identity: str | None = None
656
+ ):
657
+ """Create a session; `identity` lets a worktree named before the session share its ID."""
658
+ root = root or session_root()
659
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
660
+ identity = identity or str(uuid4())
661
+ directory = root / identity
662
+ directory.mkdir(mode=0o700)
663
+ from pcode.worktree import head_commit, project_checkout
664
+
665
+ project = project_checkout(workspace)
666
+ info = SessionInfo(
667
+ id=identity,
668
+ project=str(project) if project else None,
669
+ start_commit=head_commit(workspace) if project else None,
670
+ model=model,
671
+ workspace=str(workspace.resolve()),
672
+ created=now(),
673
+ updated=now(),
674
+ packages=versions(),
675
+ )
676
+ session = cls(directory, info)
677
+ try:
678
+ session.save_info()
679
+ except BaseException:
680
+ session.close()
681
+ raise
682
+ return session
683
+
684
+ @classmethod
685
+ def open(
686
+ cls,
687
+ selector: str,
688
+ root: Path | None = None,
689
+ workspace: Path | None = None,
690
+ *,
691
+ fork_if_open: bool = False,
692
+ fork: bool = False,
693
+ ):
694
+ """Open a session; `fork_if_open` continues a copy of one open elsewhere.
695
+
696
+ `fork` always continues a copy, leaving the original as it was.
697
+ """
698
+ path = resolve_session(selector, root, workspace)
699
+ if fork:
700
+ session = cls.fork(path)
701
+ session.fork_requested = True
702
+ return session
703
+ try:
704
+ return cls(path, read_info(path))
705
+ except SessionBusy:
706
+ if not fork_if_open:
707
+ raise
708
+ return cls.fork(path)
709
+
710
+ @classmethod
711
+ def fork(cls, source: Path):
712
+ """Copy a session, which another process may be writing, to continue it separately.
713
+
714
+ Nothing here takes the source's lock or writes to its directory. The
715
+ journal is copied before the step store, so every turn the copied
716
+ journal calls finished already has its checkpoint in the copied store,
717
+ and the store may be a step ahead of the journal but never behind it. A
718
+ torn final journal line is skipped on read, as after a crash.
719
+
720
+ A turn still running in the source is copied the way a crash leaves
721
+ one: recovery continues from its last settled step, or from the turn
722
+ before it when it has settled none, and `/resend` carries it on.
723
+ """
724
+ for name in ("session.json", "transcript.jsonl", "steps.sqlite3"):
725
+ if (source / name).is_symlink() or not (source / name).is_file():
726
+ raise SessionError("The session to copy is missing or symlinked.")
727
+ info = read_info(source)
728
+ identity = str(uuid4())
729
+ directory = source.parent / identity
730
+ directory.mkdir(mode=0o700)
731
+ try:
732
+ for name in ("transcript.jsonl", "steps.sqlite3"):
733
+ private_file(directory / name)
734
+ shutil.copyfile(source / "transcript.jsonl", directory / "transcript.jsonl")
735
+ # The backup API reads one consistent snapshot of a WAL store that
736
+ # its owner keeps writing; a file copy could tear it.
737
+ origin = sqlite3.connect(source / "steps.sqlite3")
738
+ try:
739
+ copy = sqlite3.connect(directory / "steps.sqlite3")
740
+ try:
741
+ origin.backup(copy)
742
+ finally:
743
+ copy.close()
744
+ finally:
745
+ origin.close()
746
+ stamp = now()
747
+ info = info.model_copy(
748
+ update={"id": identity, "created": stamp, "updated": stamp, "packages": versions()}
749
+ )
750
+ session = cls(directory, info)
751
+ except BaseException:
752
+ shutil.rmtree(directory, ignore_errors=True)
753
+ raise
754
+ session.forked_from = source.name
755
+ try:
756
+ # Written last: until the manifest exists, listings skip the copy.
757
+ session.save_info()
758
+ except BaseException:
759
+ session.abandon()
760
+ raise
761
+ return session
762
+
763
+ def abandon(self) -> None:
764
+ """Close after a resume that did not go ahead; a copy made for it is removed."""
765
+ try:
766
+ if self.forked_from:
767
+ shutil.rmtree(self.directory, ignore_errors=True)
768
+ finally:
769
+ self.close()
770
+
771
+ def save_info(self, *, touch: bool = True) -> None:
772
+ """Write the manifest; `touch=False` keeps `updated`, the /resume sort and date."""
773
+ if touch:
774
+ self.info.updated = now()
775
+ # Atomic replacement: interruption cannot leave half a manifest.
776
+ temporary = None
777
+ try:
778
+ with tempfile.NamedTemporaryFile(mode="w", dir=self.directory, delete=False) as file:
779
+ temporary = Path(file.name)
780
+ file.write(self.info.model_dump_json(indent=2))
781
+ file.flush()
782
+ os.fsync(file.fileno())
783
+ temporary.replace(self.directory / "session.json")
784
+ finally:
785
+ if temporary is not None:
786
+ temporary.unlink(missing_ok=True)
787
+
788
+ def append(self, kind: str, *, sync: bool = False, **data) -> None:
789
+ record = {"version": 1, "time": now(), "kind": kind, **data}
790
+ # Conversation content is intentionally retained losslessly. The directory
791
+ # is private; credentials/transport headers are never passed to this API.
792
+ with (self.directory / "transcript.jsonl").open("ab+") as file:
793
+ if file.tell():
794
+ file.seek(-1, os.SEEK_END)
795
+ if file.read(1) != b"\n":
796
+ # Preserve a torn final record for debugging, but don't glue
797
+ # the next valid event onto it after a restart.
798
+ file.write(b"\n")
799
+ file.write((json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8"))
800
+ file.flush()
801
+ if sync:
802
+ os.fsync(file.fileno())
803
+
804
+ self.tree.consume(record)
805
+
806
+ def record_error(
807
+ self,
808
+ error: BaseException,
809
+ *,
810
+ run_id: str,
811
+ provider_context: dict[str, str] | None = None,
812
+ detail: str = "",
813
+ ) -> Path | None:
814
+ """Append the traceback the transcript's bounded `error` summary cannot carry.
815
+
816
+ A type and a message name the symptom; only frames name the line. This
817
+ is a separate append-only file rather than a transcript record so a
818
+ crash loop cannot push conversation history out of a replay window, and
819
+ so nothing that renders the transcript has to filter it out.
820
+
821
+ Diagnostics must never replace the failure being diagnosed: a directory
822
+ that has gone away or read-only is silently accepted.
823
+ """
824
+ path = self.directory / "errors.log"
825
+ try:
826
+ private_file(path)
827
+ with path.open("a", encoding="utf-8") as file:
828
+ file.write(f"--- {now()} run {run_id} ---\n")
829
+ if detail:
830
+ file.write(redact(detail) + "\n")
831
+ if provider_context:
832
+ file.write(
833
+ "Configured provider: " + redact(json.dumps(provider_context)) + "\n"
834
+ )
835
+ file.write(f"{error_report(error)}\n")
836
+ except OSError:
837
+ return None
838
+ return path
839
+
840
+ def event(self, event, *, run_id: str = "") -> None:
841
+ """Record a display event, named with the turn that produced it.
842
+
843
+ Turn membership used to be inferred from file order, which only holds
844
+ while one turn runs at a time. An event that names its own run (tool
845
+ events already do) keeps that name; everything else takes the caller's.
846
+ """
847
+ data = asdict(event)
848
+ data["run_id"] = data.get("run_id") or run_id
849
+ self.append(type(event).__name__, **data)
850
+
851
+ def refresh(self) -> bool:
852
+ """The owner's tree and manifest are already current, and hold unsaved state
853
+ (navigation, unsaved turns' history) that re-reading the files would drop."""
854
+ return False
855
+
856
+ async def history_at(self, identity: str | None):
857
+ path = self.tree.path(identity)
858
+ for run_id in reversed(path):
859
+ node = self.tree.nodes[run_id]
860
+ if node.kind == "compaction":
861
+ return deepcopy(node.history)
862
+ # Never opt into interrupted snapshots with pending tool calls.
863
+ snapshot = await self.store.latest_snapshot(run_id=run_id)
864
+ if snapshot is not None:
865
+ return snapshot.messages
866
+ if self.tree.nodes[run_id].status == "completed":
867
+ raise SessionError("The selected turn's checkpoint is missing; branch unchanged.")
868
+ return []
869
+
870
+ async def recover(self):
871
+ """Restore settled history without replaying tools or resolving their effects.
872
+
873
+ Interrupted tools may have changed the workspace. Keep their ledger entries
874
+ intact for diagnostics, but do not require review to continue the session.
875
+ """
876
+ if self.tree.nodes:
877
+ return await self.history_at(self.tree.active)
878
+ # Compatibility with sessions whose journal predates run IDs. Delegated
879
+ # runs are stored too, and a sub-agent's history is not this conversation.
880
+ runs = [
881
+ run
882
+ for run in await self.store.list_runs(conversation_id=self.info.id)
883
+ if run.parent_run_id is None
884
+ ]
885
+ for run in reversed(runs):
886
+ snapshot = await self.store.latest_snapshot(run_id=run.run_id)
887
+ if snapshot is not None:
888
+ return snapshot.messages
889
+ return []
890
+
891
+ def close(self) -> None:
892
+ self.lock.release()