symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1195 @@
1
+ """JSONL persistence for coding-agent sessions.
2
+
3
+ One append-only transcript per session under ``<root>/<session_id>/transcript.jsonl``.
4
+ The transcript is the source of truth: conversation messages are stored as
5
+ typed events and compaction stores a snapshot event. Runtime context is
6
+ reconstructed from those events; no separate context file is written.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fcntl
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import threading
18
+ from contextlib import contextmanager
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Union
23
+
24
+ from coding_agent.addons.persistence.artifacts import (
25
+ append_bytes,
26
+ publish,
27
+ refresh_snapshot,
28
+ safe_path,
29
+ snapshot,
30
+ )
31
+ from coding_agent.addons.persistence.images import archive, hydrate
32
+ from core_ai.content import text_from_content
33
+ from core_ai.types import Message
34
+ from core_harness import Checkpoint
35
+ from core_harness.context import COMPACTED_CONTEXT_MARK
36
+ from coding_agent.addons.persistence.collection import (
37
+ COLLECTABLE_EVENT_TYPES,
38
+ TERMINAL_EVENT_TYPES,
39
+ apply_collection,
40
+ event_key,
41
+ is_collected,
42
+ )
43
+
44
+ _SESSION_ID = re.compile(r"^[A-Za-z0-9._-]+$")
45
+ _SKIP_EVENTS = frozenset({"text_delta", "reasoning_delta", "tool_call_delta"})
46
+ _MESSAGE_FIELDS = ("role", "content", "tool_calls", "tool_call_id", "tool_call_metadata")
47
+ _MESSAGE_TYPES = frozenset({"system", "user", "assistant", "tool_result", "message"})
48
+
49
+
50
+ def _move_client_sessions(source: Path, target: Path, client: str) -> None:
51
+ """Move bundles already tagged for ``client`` into that product's directory.
52
+
53
+ Untagged chats stay where they are. A TUI cannot tell an old Zeron chat
54
+ from an old TUI chat once they share a folder, so only an explicit tag moves.
55
+ """
56
+ if not source.is_dir():
57
+ return
58
+ for bundle in source.iterdir():
59
+ if not bundle.is_dir() or bundle.is_symlink():
60
+ continue
61
+ meta_path = bundle / "metadata.json"
62
+ if not meta_path.is_file():
63
+ continue
64
+ try:
65
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
66
+ except (OSError, ValueError):
67
+ continue
68
+ if not isinstance(meta, dict) or meta.get("client") != client:
69
+ continue
70
+ destination = target / bundle.name
71
+ if destination.exists():
72
+ continue
73
+ shutil.move(str(bundle), str(destination))
74
+
75
+
76
+ def _utc_now() -> str:
77
+ return datetime.now(timezone.utc).isoformat()
78
+
79
+
80
+ def sessions_dir(
81
+ workspace: Union[str, Path] | None = None,
82
+ *,
83
+ client: str = "",
84
+ ) -> Path:
85
+ """Return the session directory for one product, migrating old sessions.
86
+
87
+ The TUI keeps its own directory so resume cannot see Zeron chats. Other
88
+ clients stay in the shared directory. Older releases stored sessions in
89
+ ``<workspace>/.sessions``; those files move into the shared directory on
90
+ first use. Migration is file-by-file and only removes the legacy directory
91
+ after every move has succeeded, so an interrupted migration remains resumable.
92
+ """
93
+ root = (Path.home() / ".symphony").expanduser().resolve()
94
+ if client == "tui":
95
+ target = root / "tui" / "sessions"
96
+ shared = root / "sessions"
97
+ target.mkdir(parents=True, exist_ok=True)
98
+ _move_client_sessions(shared, target, "tui")
99
+ return target
100
+ target = root / "sessions"
101
+ target.mkdir(parents=True, exist_ok=True)
102
+ if workspace is not None:
103
+ legacy = Path(workspace).expanduser().resolve() / ".sessions"
104
+ if legacy.is_dir() and not legacy.is_symlink() and legacy != target:
105
+ try:
106
+ for source in legacy.iterdir():
107
+ destination = target / source.name
108
+ if source.is_file() and not source.is_symlink() and not destination.exists() and not destination.is_symlink():
109
+ shutil.move(str(source), str(destination))
110
+ if not any(legacy.iterdir()):
111
+ legacy.rmdir()
112
+ except OSError:
113
+ # Leave the legacy directory in place; resume can retry later.
114
+ pass
115
+ return target
116
+
117
+
118
+ def _entry_seq(entry: Mapping[str, Any]) -> int:
119
+ try:
120
+ return int(entry.get("seq", 0) or 0)
121
+ except (TypeError, ValueError):
122
+ return 0
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class SessionSummary:
127
+ """Small displayable summary of a persisted session."""
128
+
129
+ session_id: str
130
+ updated_at: str
131
+ message_count: int = 0
132
+ first_message: str = ""
133
+ client: str = ""
134
+
135
+
136
+ class _SessionIndex:
137
+ """Incremental view of one journal. Reads are O(1) or O(result)."""
138
+
139
+ def __init__(self, directory: Path) -> None:
140
+ # Archived images resolve against the session directory when a
141
+ # message is validated, as the full loaders did before the index.
142
+ self.directory = directory
143
+ self.entries: List[Dict[str, Any]] = []
144
+ self.size = -1
145
+ self.high_seq = 0
146
+ self.event_keys: set[tuple[str, int]] = set()
147
+ self.collected: set[tuple[str, int]] = set()
148
+ self.collectable: Dict[str, List[tuple[str, int]]] = {}
149
+ self.run_started: set[str] = set()
150
+ self.latest_terminal_run = ""
151
+ self.messages: List[Dict[str, Any]] = []
152
+ self.hydrated: List[Message] = []
153
+ self.context: List[Dict[str, Any]] = []
154
+ self.hydrated_context: List[Message] = []
155
+ self.context_through = -1
156
+ self.latest_compaction: Optional[Dict[str, Any]] = None
157
+ self.compactions: List[Dict[str, Any]] = []
158
+ self.latest_checkpoint: Optional[Dict[str, Any]] = None
159
+ self.children: Dict[str, dict[str, Any]] = {}
160
+ self.child_order: List[str] = []
161
+ self.message_count = 0
162
+ self.first_message = ""
163
+ self.child_ids: set[str] = set()
164
+
165
+ def reset(self, entries: List[Dict[str, Any]], size: int) -> None:
166
+ fresh = _SessionIndex(self.directory)
167
+ self.__dict__.update(fresh.__dict__)
168
+ self.entries = entries
169
+ self.size = size
170
+ self._ingest(entries)
171
+
172
+ def extend(self, entries: List[Dict[str, Any]], size: int) -> None:
173
+ self.entries.extend(entries)
174
+ self.size = size
175
+ self._ingest(entries)
176
+
177
+ def _ingest(self, entries: Sequence[Dict[str, Any]]) -> None:
178
+ for entry in entries:
179
+ seq = _entry_seq(entry)
180
+ if seq > self.high_seq:
181
+ self.high_seq = seq
182
+ self._note_event(entry)
183
+ self._note_message(entry, seq)
184
+ self._note_compaction(entry)
185
+ self._note_checkpoint(entry)
186
+ self._note_child(entry)
187
+ self._note_listing(entry)
188
+
189
+ def _note_event(self, entry: Dict[str, Any]) -> None:
190
+ event_type = str(entry.get("event_type") or "")
191
+ if not event_type or entry.get("type") == "message":
192
+ return
193
+ payload = entry.get("payload")
194
+ if not isinstance(payload, dict):
195
+ return
196
+ key = event_key(payload)
197
+ if key is not None:
198
+ self.event_keys.add(key)
199
+ if event_type == "collected" or is_collected(payload):
200
+ if key is not None:
201
+ self.collected.add(key)
202
+ return
203
+ run_id = str(payload.get("run_id") or "")
204
+ if not run_id or key is None:
205
+ return
206
+ if event_type == "run_started":
207
+ self.run_started.add(run_id)
208
+ return
209
+ if event_type in TERMINAL_EVENT_TYPES:
210
+ self.latest_terminal_run = run_id
211
+ return
212
+ if run_id in self.run_started and event_type in COLLECTABLE_EVENT_TYPES:
213
+ bucket = self.collectable.setdefault(run_id, [])
214
+ if key not in self.collected and key not in set(bucket):
215
+ bucket.append(key)
216
+
217
+ def _note_message(self, entry: Dict[str, Any], seq: int) -> None:
218
+ payload = JsonlPersistence._message_payload_from_entry(entry)
219
+ if payload is None:
220
+ return
221
+ self.messages.append(payload)
222
+ self.hydrated.append(Message.model_validate(hydrate(payload, self.directory)))
223
+ if self.latest_compaction is None or seq > self.context_through:
224
+ self.context.append(payload)
225
+ self.hydrated_context.append(self.hydrated[-1])
226
+
227
+ def _note_compaction(self, entry: Dict[str, Any]) -> None:
228
+ if entry.get("type") not in {"compaction", "compacted"}:
229
+ return
230
+ self.compactions.append(entry)
231
+ self.latest_compaction = entry
232
+ self.context = []
233
+ self.hydrated_context = []
234
+ self.context_through = int(entry.get("through_seq", 0) or 0)
235
+ system = entry.get("system")
236
+ if not isinstance(system, dict):
237
+ system = next((item.get("message") for item in self.entries
238
+ if item.get("type") == "message"
239
+ and isinstance(item.get("message"), dict)
240
+ and item["message"].get("role") == "system"), None)
241
+ if isinstance(system, dict):
242
+ item = dict(system)
243
+ self.context.append(item)
244
+ self.hydrated_context.append(Message.model_validate(hydrate(item, self.directory)))
245
+ summary = entry.get("summary")
246
+ if isinstance(summary, dict):
247
+ item = dict(summary)
248
+ self.context.append(item)
249
+ self.hydrated_context.append(Message.model_validate(hydrate(item, self.directory)))
250
+ elif isinstance(summary, str) and summary:
251
+ item = {"role": str(entry.get("summary_role") or "user"), "content": summary}
252
+ self.context.append(item)
253
+ self.hydrated_context.append(Message.model_validate(item))
254
+
255
+ def _note_checkpoint(self, entry: Dict[str, Any]) -> None:
256
+ if entry.get("type") == "checkpoint":
257
+ self.latest_checkpoint = entry
258
+
259
+ def _note_child(self, entry: Dict[str, Any]) -> None:
260
+ kind = entry.get("type")
261
+ if kind == "spawn":
262
+ child_id = str(entry.get("child_id") or "")
263
+ if not child_id:
264
+ return
265
+ metadata = dict(entry.get("metadata") or {})
266
+ self.children[child_id] = {
267
+ **metadata,
268
+ "status": str(entry.get("status") or "running"),
269
+ "output_text": str(entry.get("output_text") or ""),
270
+ }
271
+ if child_id not in self.child_order:
272
+ self.child_order.append(child_id)
273
+ elif kind == "spawn_status":
274
+ child_id = str(entry.get("child_id") or "")
275
+ child = self.children.get(child_id)
276
+ if child is None:
277
+ return
278
+ child["status"] = str(entry.get("status") or child["status"])
279
+ child["output_text"] = str(entry.get("output_text") or child.get("output_text") or "")
280
+
281
+ def _note_listing(self, entry: Dict[str, Any]) -> None:
282
+ if entry.get("type") == "spawn":
283
+ child_session = str(entry.get("session_id") or "")
284
+ if child_session:
285
+ self.child_ids.add(child_session)
286
+ payload = JsonlPersistence._message_payload_from_entry(entry)
287
+ if payload is None:
288
+ return
289
+ self.message_count += 1
290
+ if self.first_message:
291
+ return
292
+ text = text_from_content(payload.get("content")).strip()
293
+ if payload.get("role") == "user" and text and not text.startswith(COMPACTED_CONTEXT_MARK):
294
+ self.first_message = text
295
+
296
+ def context_payloads(self) -> List[Dict[str, Any]]:
297
+ if self.latest_compaction is None:
298
+ return list(self.messages)
299
+ return list(self.context)
300
+
301
+ def listing(self, size: int) -> Dict[str, Any]:
302
+ return {
303
+ "version": 1,
304
+ "transcript_size": size,
305
+ "message_count": self.message_count,
306
+ "first_message": self.first_message,
307
+ "child_ids": sorted(self.child_ids),
308
+ }
309
+
310
+
311
+ class JsonlPersistence:
312
+ """Append-only JSONL store for harness conversations, checkpoints, and journal."""
313
+
314
+ def __init__(self, root: Union[str, Path]) -> None:
315
+ self.root = Path(root).expanduser().resolve()
316
+ self.root.mkdir(parents=True, exist_ok=True)
317
+ self._lock = threading.Lock()
318
+ self._indexes: Dict[str, _SessionIndex] = {}
319
+ self._listing_sizes: Dict[str, int] = {}
320
+ self._held: Dict[str, list[int]] = {}
321
+ # Product that owns new sessions ("tui", "zeron", ...). Empty means unknown.
322
+ self.client: str = ""
323
+ # Product fields (goal, todo, live background jobs) merged into every
324
+ # checkpoint so an interrupted run can be continued later.
325
+ self.checkpoint_metadata: Callable[[], Dict[str, Any]] = dict
326
+
327
+ def session_dir(self, session_id: str) -> Path:
328
+ """Return a validated session artifact directory (without creating it)."""
329
+ if session_id in {".", ".."} or not _SESSION_ID.fullmatch(session_id):
330
+ raise ValueError(f"invalid session_id: {session_id!r}")
331
+ return safe_path(self.root, session_id)
332
+
333
+ def _path(self, session_id: str) -> Path:
334
+ return safe_path(self.session_dir(session_id), "transcript.jsonl")
335
+
336
+ def _acquire_session(self, session_id: str) -> None:
337
+ held = self._held.get(session_id)
338
+ if held is not None:
339
+ held[1] += 1
340
+ return
341
+ directory = self.session_dir(session_id)
342
+ directory.mkdir(parents=True, exist_ok=True)
343
+ lock_path = safe_path(directory, ".lock")
344
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
345
+ try:
346
+ fcntl.flock(fd, fcntl.LOCK_EX)
347
+ except BaseException:
348
+ os.close(fd)
349
+ raise
350
+ self._held[session_id] = [fd, 1]
351
+
352
+ def _release_session(self, session_id: str) -> None:
353
+ held = self._held[session_id]
354
+ held[1] -= 1
355
+ if held[1] == 0:
356
+ fd = held[0]
357
+ del self._held[session_id]
358
+ try:
359
+ fcntl.flock(fd, fcntl.LOCK_UN)
360
+ finally:
361
+ os.close(fd)
362
+
363
+ @contextmanager
364
+ def _session_lock(self, session_id: str) -> Iterator[None]:
365
+ """Exclusive cross-process lock for migrate and append."""
366
+ self._acquire_session(session_id)
367
+ try:
368
+ yield
369
+ finally:
370
+ self._release_session(session_id)
371
+
372
+ def _metadata_path(self, session_id: str) -> Path:
373
+ return safe_path(self.session_dir(session_id), "metadata.json")
374
+
375
+ def _read_metadata(self, session_id: str) -> Optional[Dict[str, Any]]:
376
+ path = self._metadata_path(session_id)
377
+ if not path.exists():
378
+ return None
379
+ try:
380
+ saved = json.loads(path.read_text(encoding="utf-8"))
381
+ except (OSError, ValueError) as exc:
382
+ raise ValueError(f"session bundle metadata unreadable: {session_id}") from exc
383
+ if not isinstance(saved, dict):
384
+ raise ValueError(f"session bundle metadata unreadable: {session_id}")
385
+ return saved
386
+
387
+ def _require_version(self, session_id: str) -> Optional[Dict[str, Any]]:
388
+ """Refuse a bundle whose version is missing or not 1."""
389
+ meta = self._read_metadata(session_id)
390
+ transcript = self._path(session_id)
391
+ has_body = transcript.is_file() and not transcript.is_symlink() and transcript.stat().st_size > 0
392
+ if meta is None:
393
+ if has_body:
394
+ raise ValueError(f"session bundle version missing: {session_id}")
395
+ return None
396
+ if meta.get("version") != 1:
397
+ raise ValueError(f"unsupported session bundle version: {meta.get('version')!r}")
398
+ return meta
399
+
400
+ def _write_metadata(self, session_id: str, metadata: Dict[str, Any]) -> None:
401
+ snapshot(self._metadata_path(session_id), {**metadata, "version": 1, "session_id": session_id})
402
+
403
+ def _ensure_metadata(self, session_id: str) -> Dict[str, Any]:
404
+ meta = self._require_version(session_id)
405
+ if meta is not None:
406
+ return meta
407
+ created = _utc_now()
408
+ meta = {
409
+ "version": 1,
410
+ "session_id": session_id,
411
+ "created_at": created,
412
+ "updated_at": created,
413
+ "client": self.client or "",
414
+ }
415
+ self._write_metadata(session_id, meta)
416
+ return meta
417
+
418
+ def _legacy_path(self, session_id: str) -> Path:
419
+ return safe_path(self.root, f"{session_id}.jsonl")
420
+
421
+ def _mark(self, path: Path) -> int:
422
+ if not path.is_file() or path.is_symlink():
423
+ return -1
424
+ return path.stat().st_size
425
+
426
+ @staticmethod
427
+ def _parse_legacy(raw: bytes) -> tuple[int, List[Dict[str, Any]]]:
428
+ skipped = 0
429
+ entries: List[Dict[str, Any]] = []
430
+ for raw_line in raw.decode("utf-8", errors="replace").splitlines():
431
+ line = raw_line.strip()
432
+ if not line:
433
+ continue
434
+ try:
435
+ item = json.loads(line)
436
+ except json.JSONDecodeError:
437
+ skipped += 1
438
+ continue
439
+ if isinstance(item, dict):
440
+ entries.append(item)
441
+ else:
442
+ skipped += 1
443
+ return skipped, entries
444
+
445
+ @staticmethod
446
+ def _strip_imported_jobs(entry: Dict[str, Any]) -> Dict[str, Any]:
447
+ """Drop background job pids that arrived in an unvalidated legacy file."""
448
+ if entry.get("type") != "checkpoint":
449
+ return entry
450
+ metadata = entry.get("metadata")
451
+ if not isinstance(metadata, dict) or "background_jobs" not in metadata:
452
+ return entry
453
+ return {**entry, "metadata": {key: value for key, value in metadata.items() if key != "background_jobs"}}
454
+
455
+ def _quarantine_legacy(self, session_id: str, raw: bytes, skipped: int) -> None:
456
+ digest = hashlib.sha256(raw).hexdigest()
457
+ dest = safe_path(self.session_dir(session_id), "quarantine", f"{digest}.jsonl")
458
+ if not dest.is_file():
459
+ publish(dest, raw)
460
+ meta = self._read_metadata(session_id) or {
461
+ "version": 1, "session_id": session_id, "created_at": _utc_now(),
462
+ }
463
+ meta["quarantine"] = {"sha256": digest, "skipped_lines": skipped, "bytes": len(raw)}
464
+ self._write_metadata(session_id, meta)
465
+
466
+ def _remember_legacy(self, session_id: str, raw: bytes) -> None:
467
+ meta = self._ensure_metadata(session_id)
468
+ meta["legacy_import"] = {"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}
469
+ self._write_metadata(session_id, meta)
470
+
471
+ @staticmethod
472
+ def _entry_key(entry: Dict[str, Any]) -> tuple[Any, ...]:
473
+ seq = entry.get("seq")
474
+ if isinstance(seq, int) and not isinstance(seq, bool):
475
+ return ("seq", entry.get("type"), seq)
476
+ return ("json", json.dumps(entry, sort_keys=True, ensure_ascii=False))
477
+
478
+ def _encode(self, entries: List[Dict[str, Any]]) -> bytes:
479
+ return "".join(json.dumps(item, ensure_ascii=False) + "\n" for item in entries).encode("utf-8")
480
+
481
+ def _publish_transcript(self, session_id: str, entries: List[Dict[str, Any]]) -> None:
482
+ """Publish a transcript only while it is still empty. Never replace appended bytes."""
483
+ path = self._path(session_id)
484
+ if path.is_symlink():
485
+ raise ValueError(f"unsafe artifact symlink: {path}")
486
+ if path.is_file() and path.stat().st_size > 0:
487
+ return
488
+ self._ensure_metadata(session_id)
489
+ publish(path, self._encode(entries))
490
+ self._indexes.pop(session_id, None)
491
+
492
+ def _sync_legacy(self, session_id: str) -> None:
493
+ """Fold a newer flat JSONL into the bundle. Do not replace an appended transcript."""
494
+ legacy = self._legacy_path(session_id)
495
+ if not legacy.exists():
496
+ return
497
+ if legacy.is_symlink() or not legacy.is_file():
498
+ return
499
+ raw = legacy.read_bytes()
500
+ path = self._path(session_id)
501
+ transcript_size = path.stat().st_size if path.is_file() and not path.is_symlink() else 0
502
+ meta = self._read_metadata(session_id)
503
+ if transcript_size > 0:
504
+ if meta is None or meta.get("version") != 1:
505
+ raise ValueError(
506
+ f"session bundle version missing: {session_id}" if meta is None
507
+ else f"unsupported session bundle version: {meta.get('version')!r}"
508
+ )
509
+ recorded = meta.get("legacy_import") if isinstance(meta, dict) else None
510
+ if not isinstance(recorded, dict) or not isinstance(recorded.get("bytes"), int):
511
+ if transcript_size <= 0:
512
+ self._import_legacy_bytes(session_id, raw, raw)
513
+ elif legacy.stat().st_mtime > path.stat().st_mtime:
514
+ self._import_legacy_bytes(session_id, raw, raw, only_missing=True)
515
+ else:
516
+ self._remember_legacy(session_id, raw)
517
+ return
518
+ cursor = recorded["bytes"]
519
+ if cursor < 0 or cursor > len(raw):
520
+ self._note_rejected_legacy(session_id, raw)
521
+ return
522
+ prefix = raw[:cursor]
523
+ if hashlib.sha256(prefix).hexdigest() != recorded.get("sha256"):
524
+ self._note_rejected_legacy(session_id, raw)
525
+ return
526
+ if len(raw) == cursor:
527
+ return
528
+ self._import_legacy_bytes(session_id, raw, raw[cursor:])
529
+
530
+ def _note_rejected_legacy(self, session_id: str, raw: bytes) -> None:
531
+ skipped, _entries = self._parse_legacy(raw)
532
+ if skipped:
533
+ self._quarantine_legacy(session_id, raw, skipped)
534
+ digest = hashlib.sha256(raw).hexdigest()
535
+ meta = self._ensure_metadata(session_id)
536
+ legacy_import = dict(meta.get("legacy_import") or {})
537
+ legacy_import["rejected_sha256"] = digest
538
+ meta["legacy_import"] = legacy_import
539
+ self._write_metadata(session_id, meta)
540
+
541
+ def _import_legacy_bytes(
542
+ self, session_id: str, raw: bytes, chunk: bytes, *, only_missing: bool = False
543
+ ) -> None:
544
+ skipped, entries = self._parse_legacy(chunk)
545
+ if skipped:
546
+ self._quarantine_legacy(session_id, raw, skipped)
547
+ entries = [self._strip_imported_jobs(entry) for entry in entries]
548
+ entries = archive(entries, self.session_dir(session_id))
549
+ path = self._path(session_id)
550
+ transcript_size = path.stat().st_size if path.is_file() and not path.is_symlink() else 0
551
+ if transcript_size <= 0 and not only_missing:
552
+ self._publish_transcript(session_id, entries)
553
+ elif entries:
554
+ if only_missing:
555
+ existing = {self._entry_key(entry) for entry in self._load_transcript(path)}
556
+ entries = [entry for entry in entries if self._entry_key(entry) not in existing]
557
+ if entries:
558
+ self._append_entries(session_id, entries)
559
+ self._remember_legacy(session_id, raw)
560
+
561
+ def _load_transcript(self, path: Path) -> List[Dict[str, Any]]:
562
+ if not path.is_file() or path.is_symlink():
563
+ return []
564
+ entries: List[Dict[str, Any]] = []
565
+ with path.open(encoding="utf-8") as handle:
566
+ for raw in handle:
567
+ line = raw.strip()
568
+ if not line:
569
+ continue
570
+ try:
571
+ item = json.loads(line)
572
+ except json.JSONDecodeError:
573
+ continue
574
+ if isinstance(item, dict):
575
+ entries.append(item)
576
+ return entries
577
+
578
+ def _artifacts(
579
+ self, session_id: str, entries: List[Dict[str, Any]], *, updated: bool = True
580
+ ) -> None:
581
+ """Publish metadata, compaction and checkpoint snapshots for ``entries``.
582
+
583
+ ``updated`` is the append path: ``entries`` are the lines just written
584
+ and every artifact is published. Otherwise ``entries`` is a whole
585
+ journal that was just read, and only artifacts that are missing or
586
+ differ from what the journal implies are rewritten (crash repair).
587
+ """
588
+ write = snapshot if updated else refresh_snapshot
589
+ metadata = self._require_version(session_id) or {}
590
+ directory = self.session_dir(session_id)
591
+ created = next((item.get("created_at") for item in entries
592
+ if item.get("type") == "header"), None) or _utc_now()
593
+ write(self._metadata_path(session_id), {
594
+ **metadata, "version": 1, "session_id": session_id,
595
+ "created_at": metadata.get("created_at") or created,
596
+ "updated_at": _utc_now() if updated else metadata.get("updated_at", created),
597
+ })
598
+ checkpoint: Optional[Dict[str, Any]] = None
599
+ for entry in entries:
600
+ if entry.get("type") in {"compaction", "compacted"}:
601
+ seq = entry.get("seq", 0)
602
+ if isinstance(seq, bool) or not isinstance(seq, int):
603
+ continue
604
+ write(safe_path(directory, "compactions", f"{seq}.json"), {**entry, "version": 1})
605
+ elif entry.get("type") == "checkpoint":
606
+ # Each checkpoint replaces the file; only the last one survives.
607
+ checkpoint = entry
608
+ if checkpoint is not None:
609
+ write(safe_path(directory, "checkpoint.json"),
610
+ {key: value for key, value in {**checkpoint, "version": 1}.items()
611
+ if key != "messages"})
612
+
613
+ def _read_entries(self, session_id: str) -> List[Dict[str, Any]]:
614
+ """The journal, parsed once per process and then kept current by appends.
615
+
616
+ The transcript only grows, and every append in this process updates
617
+ the cache and its size mark under the session lock. A size that still
618
+ matches the mark means nothing else wrote, so the cached list is
619
+ returned without re-reading or republishing artifacts. Any other size
620
+ (another process appended, a torn write, first use) re-reads the file
621
+ and repairs the artifacts.
622
+ """
623
+ with self._session_lock(session_id):
624
+ self._sync_legacy(session_id)
625
+ self._require_version(session_id)
626
+ path = self._path(session_id)
627
+ mark = self._mark(path)
628
+ index = self._indexes.get(session_id)
629
+ if index is not None and index.size == mark:
630
+ return index.entries
631
+ entries = self._load_transcript(path) if mark >= 0 else []
632
+ if mark >= 0:
633
+ self._artifacts(session_id, entries, updated=False)
634
+ index = self._indexes.setdefault(session_id, _SessionIndex(self.session_dir(session_id)))
635
+ index.reset(entries, mark)
636
+ return entries
637
+
638
+ def _cached_entries(self, session_id: str) -> List[Dict[str, Any]]:
639
+ """Return the journal without reading it when its cached size is current."""
640
+ path = self._path(session_id)
641
+ mark = self._mark(path)
642
+ index = self._indexes.get(session_id)
643
+ if index is not None and index.size == mark:
644
+ return index.entries
645
+ return self._read_entries(session_id)
646
+
647
+ def _stamp_client(self, session_id: str) -> None:
648
+ """Record the product once. A later client must not overwrite it."""
649
+ client = self.client or ""
650
+ if not client:
651
+ return
652
+ meta = self._ensure_metadata(session_id)
653
+ if meta.get("client"):
654
+ return
655
+ self._write_metadata(session_id, {**meta, "client": client, "updated_at": _utc_now()})
656
+
657
+ def _append_entries(self, session_id: str, entries: List[Dict[str, Any]]) -> None:
658
+ if not entries:
659
+ return
660
+ with self._session_lock(session_id):
661
+ self._stamp_client(session_id)
662
+ self._ensure_metadata(session_id)
663
+ path = self._path(session_id)
664
+ mark = self._mark(path)
665
+ index = self._indexes.get(session_id)
666
+ if index is None or index.size != mark:
667
+ index = self._indexes.setdefault(session_id, _SessionIndex(self.session_dir(session_id)))
668
+ index.reset(self._load_transcript(path) if mark >= 0 else [], mark)
669
+ # A crash mid-append leaves a torn last line. Start on a fresh line
670
+ # so the fragment stays one skipped line and does not swallow this
671
+ # record. This costs a one-byte read per append.
672
+ separator = b"" if self._ends_with_newline(path, mark) else b"\n"
673
+ entries = archive(entries, self.session_dir(session_id))
674
+ append_bytes(path, separator + self._encode(entries))
675
+ index.extend(entries, path.stat().st_size)
676
+ self._artifacts(session_id, entries)
677
+ self._refresh_listing(session_id)
678
+
679
+ @staticmethod
680
+ def _ends_with_newline(path: Path, size: int) -> bool:
681
+ if size <= 0:
682
+ return True
683
+ with path.open("rb") as handle:
684
+ handle.seek(size - 1)
685
+ return handle.read(1) == b"\n"
686
+
687
+ @staticmethod
688
+ def _max_seq(entries: List[Dict[str, Any]]) -> int:
689
+ return max((_entry_seq(entry) for entry in entries), default=0)
690
+
691
+ def _next_seq(self, session_id: str) -> int:
692
+ """One past the highest seq in the cached journal of ``session_id``."""
693
+ return self._indexes[session_id].high_seq + 1
694
+
695
+ def _header(self, session_id: str) -> Dict[str, Any]:
696
+ return {"type": "header", "session_id": session_id, "seq": 1, "created_at": _utc_now()}
697
+
698
+ def _message_entry(self, payload: Dict[str, Any], *, seq: int) -> Dict[str, Any]:
699
+ role = str(payload.get("role") or "message")
700
+ event_type = "tool_result" if role == "tool" else role
701
+ return {"type": "message", "event_type": event_type, "seq": seq, "message": payload, **payload}
702
+
703
+ @staticmethod
704
+ def _message_payload_from_entry(entry: Dict[str, Any]) -> Optional[Dict[str, Any]]:
705
+ if entry.get("type") == "message": # legacy JSONL
706
+ return {field: entry.get(field) for field in _MESSAGE_FIELDS}
707
+ if entry.get("type") in {"system", "user", "assistant", "tool_result"}:
708
+ return {field: entry.get(field) for field in _MESSAGE_FIELDS}
709
+ payload = entry.get("message")
710
+ if isinstance(payload, dict):
711
+ return {field: payload.get(field) for field in _MESSAGE_FIELDS}
712
+ return None
713
+
714
+ def _message_payload(self, message: Message) -> Dict[str, Any]:
715
+ dumped = message.model_dump()
716
+ return {field: dumped.get(field) for field in _MESSAGE_FIELDS}
717
+
718
+ def _canonical_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
719
+ return {field: payload.get(field) for field in _MESSAGE_FIELDS}
720
+
721
+ def _is_compact_mark(self, payload: Dict[str, Any]) -> bool:
722
+ content = payload.get("content")
723
+ text = content if isinstance(content, str) else text_from_content(content or "")
724
+ return text.startswith(COMPACTED_CONTEXT_MARK)
725
+
726
+ def _is_compact_rewrite(
727
+ self,
728
+ stored: List[Dict[str, Any]],
729
+ incoming: List[Dict[str, Any]],
730
+ ) -> bool:
731
+ """True only when the runtime conversation actually dropped history."""
732
+ if len(incoming) < len(stored):
733
+ return True
734
+ stored_marks = [item for item in stored if self._is_compact_mark(item)]
735
+ incoming_marks = [item for item in incoming if self._is_compact_mark(item)]
736
+ return bool(incoming_marks) and (
737
+ not stored_marks or stored_marks[-1] != incoming_marks[-1]
738
+ )
739
+
740
+ def _messages_from(self, entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
741
+ """Build the model context; never use this for rendering history."""
742
+ dumped: List[Dict[str, Any]] = []
743
+ latest = next(
744
+ (
745
+ entry
746
+ for entry in reversed(entries)
747
+ if entry.get("type") in {"compaction", "compacted"}
748
+ ),
749
+ None,
750
+ )
751
+ if latest is None:
752
+ return [payload for entry in entries
753
+ if (payload := self._message_payload_from_entry(entry)) is not None]
754
+
755
+ system = latest.get("system")
756
+ if not isinstance(system, dict):
757
+ system = next((entry.get("message") for entry in entries
758
+ if entry.get("type") == "message"
759
+ and entry.get("message", {}).get("role") == "system"), None)
760
+ if isinstance(system, dict):
761
+ dumped.append(dict(system))
762
+ summary = latest.get("summary")
763
+ if isinstance(summary, dict):
764
+ dumped.append(dict(summary))
765
+ elif isinstance(summary, str) and summary:
766
+ dumped.append({
767
+ "role": str(latest.get("summary_role") or "user"),
768
+ "content": summary,
769
+ })
770
+ through = int(latest.get("through_seq", 0) or 0)
771
+ for entry in entries:
772
+ try:
773
+ seq = int(entry.get("seq", 0))
774
+ except (TypeError, ValueError):
775
+ seq = 0
776
+ if seq <= through:
777
+ continue
778
+ payload = self._message_payload_from_entry(entry)
779
+ if payload is not None:
780
+ dumped.append(payload)
781
+ return dumped
782
+
783
+ def _listing_path(self, session_id: str) -> Path:
784
+ return safe_path(self.session_dir(session_id), "listing.json")
785
+
786
+ def _listing_from_entries(self, entries: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
787
+ """Extract the few fields the session picker displays."""
788
+ message_count = 0
789
+ first_message = ""
790
+ child_ids: set[str] = set()
791
+ for entry in entries:
792
+ if entry.get("type") == "spawn":
793
+ child_session = str(entry.get("session_id") or "")
794
+ if child_session:
795
+ child_ids.add(child_session)
796
+ payload = self._message_payload_from_entry(entry)
797
+ if payload is None:
798
+ continue
799
+ message_count += 1
800
+ if first_message:
801
+ continue
802
+ text = text_from_content(payload.get("content")).strip()
803
+ if payload.get("role") == "user" and text and not text.startswith(COMPACTED_CONTEXT_MARK):
804
+ first_message = text
805
+ return {
806
+ "version": 1,
807
+ "transcript_size": 0,
808
+ "message_count": message_count,
809
+ "first_message": first_message,
810
+ "child_ids": sorted(child_ids),
811
+ }
812
+
813
+ def _read_listing(self, session_id: str, transcript_size: int) -> Optional[Dict[str, Any]]:
814
+ path = self._listing_path(session_id)
815
+ try:
816
+ saved = json.loads(path.read_text(encoding="utf-8"))
817
+ except (OSError, ValueError):
818
+ return None
819
+ if (
820
+ isinstance(saved, dict)
821
+ and saved.get("version") == 1
822
+ and saved.get("transcript_size") == transcript_size
823
+ and isinstance(saved.get("message_count"), int)
824
+ and isinstance(saved.get("first_message"), str)
825
+ and isinstance(saved.get("child_ids"), list)
826
+ ):
827
+ return saved
828
+ return None
829
+
830
+ def _refresh_listing(self, session_id: str) -> Dict[str, Any]:
831
+ index = self._indexes[session_id]
832
+ size = index.size
833
+ if self._listing_sizes.get(session_id) == size:
834
+ saved = self._read_listing(session_id, size)
835
+ if saved is not None:
836
+ return saved
837
+ listing = index.listing(size)
838
+ refresh_snapshot(self._listing_path(session_id), listing)
839
+ self._listing_sizes[session_id] = size
840
+ return listing
841
+
842
+ def _session_listing(self, session_id: str) -> Dict[str, Any]:
843
+ size = self._mark(self._path(session_id))
844
+ index = self._indexes.get(session_id)
845
+ if index is not None and index.size == size:
846
+ return self._refresh_listing(session_id)
847
+ saved = self._read_listing(session_id, size)
848
+ if saved is not None:
849
+ return saved
850
+ self._read_entries(session_id)
851
+ return self._refresh_listing(session_id)
852
+
853
+ def _transcript_messages(self, entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
854
+ """Return every persisted message, including messages before compaction."""
855
+ return [payload for entry in entries
856
+ if (payload := self._message_payload_from_entry(entry)) is not None]
857
+
858
+ async def append_event(self, *, event_type: str, payload: dict[str, Any]) -> None:
859
+ if event_type in _SKIP_EVENTS:
860
+ return
861
+ session_id = str(payload.get("session_id") or "")
862
+ if not session_id:
863
+ return
864
+ with self._lock, self._session_lock(session_id):
865
+ entries = self._cached_entries(session_id)
866
+ index = self._indexes[session_id]
867
+ run_id = payload.get("run_id")
868
+ seq = payload.get("seq")
869
+ if run_id and isinstance(seq, int) and (str(run_id), seq) in index.event_keys:
870
+ return
871
+ outgoing: List[Dict[str, Any]] = []
872
+ if not entries:
873
+ outgoing.append(self._header(session_id))
874
+ outgoing.append(
875
+ {
876
+ "type": event_type,
877
+ "event_type": event_type,
878
+ "seq": seq if isinstance(seq, int) else self._next_seq(session_id),
879
+ "payload": dict(payload),
880
+ }
881
+ )
882
+ if event_type == "agent_spawned":
883
+ outgoing.append(
884
+ {
885
+ "type": "spawn",
886
+ "child_id": payload.get("child_id"),
887
+ "parent_id": payload.get("parent_id") or payload.get("agent_id"),
888
+ "parent_session_id": payload.get("session_id"),
889
+ "session_id": payload.get("child_session_id"),
890
+ "status": "running",
891
+ "output_text": "",
892
+ "metadata": dict(payload),
893
+ }
894
+ )
895
+ elif event_type in {"agent_completed", "agent_failed"}:
896
+ status = "completed" if event_type == "agent_completed" else (
897
+ "cancelled" if payload.get("error_type") == "HarnessCancelled" else "failed"
898
+ )
899
+ outgoing.append(
900
+ {
901
+ "type": "spawn_status",
902
+ "child_id": payload.get("child_id"),
903
+ "status": status,
904
+ "output_text": payload.get("output_text") or payload.get("message") or "",
905
+ }
906
+ )
907
+ self._append_entries(session_id, outgoing)
908
+ if event_type in TERMINAL_EVENT_TYPES:
909
+ self._append_collection_overlays(
910
+ session_id,
911
+ run_id=str(run_id) if run_id else None,
912
+ )
913
+
914
+ async def collect_run_events(
915
+ self,
916
+ *,
917
+ session_id: str,
918
+ run_id: str | None = None,
919
+ keys: Sequence[tuple[str, int]] | None = None,
920
+ ) -> list[tuple[str, int]]:
921
+ """Tag mid-run events as collected without rewriting historical lines.
922
+
923
+ Appends one ``collected`` overlay per untagged event. The original
924
+ journal entry stays as-is; ``load_events`` overlays ``collected: true``.
925
+ """
926
+ if not session_id:
927
+ return []
928
+ with self._lock, self._session_lock(session_id):
929
+ self._read_entries(session_id)
930
+ return self._append_collection_overlays(session_id, run_id=run_id, keys=keys)
931
+
932
+ def _append_collection_overlays(
933
+ self,
934
+ session_id: str,
935
+ *,
936
+ run_id: str | None = None,
937
+ keys: Sequence[tuple[str, int]] | None = None,
938
+ ) -> list[tuple[str, int]]:
939
+ index = self._indexes[session_id]
940
+ selected = run_id or index.latest_terminal_run
941
+ targets = list(keys) if keys is not None else index.collectable.get(selected, [])
942
+ pending = [key for key in targets if key not in index.collected]
943
+ if not pending:
944
+ return []
945
+ next_seq = index.high_seq + 1
946
+ overlays: List[Dict[str, Any]] = []
947
+ for offset, (event_run_id, event_seq) in enumerate(pending):
948
+ overlays.append(
949
+ {
950
+ "type": "collected",
951
+ "event_type": "collected",
952
+ "seq": next_seq + offset,
953
+ "payload": {
954
+ "run_id": event_run_id,
955
+ "session_id": session_id,
956
+ "seq": event_seq,
957
+ "collected": True,
958
+ },
959
+ }
960
+ )
961
+ self._append_entries(session_id, overlays)
962
+ return pending
963
+
964
+ async def annotate_run_completed(
965
+ self,
966
+ *,
967
+ session_id: str,
968
+ run_id: str,
969
+ completion_verb: str,
970
+ duration: str = "",
971
+ elapsed_seconds: float | None = None,
972
+ ) -> None:
973
+ """Persist completed-run display fields without rewriting journal lines."""
974
+ if not session_id or not run_id or not completion_verb:
975
+ return
976
+ payload: dict[str, Any] = {
977
+ "session_id": session_id,
978
+ "run_id": run_id,
979
+ "completion_verb": completion_verb,
980
+ }
981
+ if duration:
982
+ payload["duration"] = duration
983
+ if elapsed_seconds is not None:
984
+ payload["elapsed_seconds"] = elapsed_seconds
985
+ await self.append_event(event_type="run_completed_meta", payload=payload)
986
+
987
+ async def load_events(self, *, session_id: str) -> list[tuple[str, dict[str, Any]]]:
988
+ with self._lock:
989
+ entries = self._cached_entries(session_id)
990
+ directory = self.session_dir(session_id)
991
+ collected = self._indexes[session_id].collected
992
+ events = [
993
+ (str(entry.get("event_type") or ""), entry.get("payload"))
994
+ for entry in entries
995
+ if entry.get("event_type") and entry.get("type") != "message" and isinstance(entry.get("payload"), dict)
996
+ ]
997
+ return apply_collection([(kind, hydrate(payload, directory)) for kind, payload in events], collected=collected)
998
+
999
+ async def load_children(self, *, parent_session_id: str) -> list[dict[str, Any]]:
1000
+ with self._lock:
1001
+ self._cached_entries(parent_session_id)
1002
+ index = self._indexes[parent_session_id]
1003
+ return [dict(index.children[child_id]) for child_id in index.child_order]
1004
+
1005
+ async def save_conversation(
1006
+ self,
1007
+ *,
1008
+ session_id: str,
1009
+ messages: List[Message],
1010
+ ) -> None:
1011
+ with self._lock, self._session_lock(session_id):
1012
+ self._stamp_client(session_id)
1013
+ entries = self._read_entries(session_id)
1014
+ incoming = archive([self._message_payload(message) for message in messages],
1015
+ self.session_dir(session_id))
1016
+ index = self._indexes[session_id]
1017
+ stored = index.context_payloads()
1018
+ if not entries:
1019
+ header = self._header(session_id)
1020
+ self._append_entries(
1021
+ session_id,
1022
+ [header, *[
1023
+ self._message_entry(item, seq=header["seq"] + index)
1024
+ for index, item in enumerate(incoming, 1)
1025
+ ]],
1026
+ )
1027
+ return
1028
+ if len(incoming) >= len(stored) and incoming[: len(stored)] == stored:
1029
+ extra = incoming[len(stored) :]
1030
+ if extra:
1031
+ next_seq = self._next_seq(session_id)
1032
+ self._append_entries(
1033
+ session_id,
1034
+ [self._message_entry(item, seq=next_seq + index) for index, item in enumerate(extra)],
1035
+ )
1036
+ return
1037
+ if not self._is_compact_rewrite(stored, incoming):
1038
+ # In-place edits (including legacy tool stubs or injected memory) must
1039
+ # not mint a compaction boundary. Re-emitting the whole
1040
+ # conversation after through_seq is what ballooned session
1041
+ # files to thousands of duplicate message lines.
1042
+ extra = incoming[len(stored) :] if len(incoming) > len(stored) else []
1043
+ if extra:
1044
+ next_seq = self._next_seq(session_id)
1045
+ self._append_entries(
1046
+ session_id,
1047
+ [self._message_entry(item, seq=next_seq + index) for index, item in enumerate(extra)],
1048
+ )
1049
+ return
1050
+ # A rewrite means the harness compacted its runtime context. Keep
1051
+ # the complete transcript and append only a checkpoint plus the
1052
+ # retained suffix; compaction must never erase TUI history.
1053
+ previous_seq = self._next_seq(session_id) - 1
1054
+ candidates = list(incoming)
1055
+ system = next((item for item in candidates if item.get("role") == "system"), None)
1056
+ if system is not None:
1057
+ candidates.remove(system)
1058
+ summary = candidates.pop(0) if candidates else {"role": "user", "content": ""}
1059
+ next_seq = self._next_seq(session_id)
1060
+ boundary = {
1061
+ "type": "compaction",
1062
+ "version": 1,
1063
+ "seq": next_seq,
1064
+ "through_seq": previous_seq,
1065
+ "summary": summary,
1066
+ "summary_role": str(summary.get("role") or "user"),
1067
+ "system": system,
1068
+ "created_at": _utc_now(),
1069
+ }
1070
+ additions = [
1071
+ self._message_entry(item, seq=next_seq + index)
1072
+ for index, item in enumerate(candidates, 1)
1073
+ ]
1074
+ self._append_entries(session_id, [boundary, *additions])
1075
+
1076
+ def _load_messages(self, session_id: str, *, transcript: bool) -> tuple[List[Message], List[Message]]:
1077
+ """Return both histories from the incremental index, hydrating once."""
1078
+ with self._lock:
1079
+ self._cached_entries(session_id)
1080
+ index = self._indexes[session_id]
1081
+ if not transcript:
1082
+ return [], list(index.hydrated_context)
1083
+ return list(index.hydrated), list(index.hydrated_context)
1084
+
1085
+ async def load_conversation(self, *, session_id: str) -> List[Message]:
1086
+ """Load the reconstructed model context (runtime view)."""
1087
+ _, context = self._load_messages(session_id, transcript=False)
1088
+ return context
1089
+
1090
+ async def load_transcript(self, *, session_id: str) -> List[Message]:
1091
+ """Load the complete persisted transcript for the TUI/history view."""
1092
+ history, _ = self._load_messages(session_id, transcript=True)
1093
+ return history
1094
+
1095
+ async def load_resume(self, *, session_id: str) -> tuple[List[Message], List[Message]]:
1096
+ """Load visible history and model context from one journal read."""
1097
+ return self._load_messages(session_id, transcript=True)
1098
+
1099
+ async def list_sessions(self) -> List[SessionSummary]:
1100
+ """List parent sessions, newest first. Child session files are omitted."""
1101
+ with self._lock:
1102
+ child_ids: set[str] = set()
1103
+ summaries: List[tuple[float, SessionSummary]] = []
1104
+ paths: Dict[str, Path] = {}
1105
+ for candidate in self.root.iterdir():
1106
+ if candidate.is_symlink():
1107
+ continue
1108
+ if candidate.is_file() and candidate.name.endswith(".jsonl") and not candidate.name.endswith(".tmp"):
1109
+ paths.setdefault(candidate.name[: -len(".jsonl")], candidate)
1110
+ elif candidate.is_dir():
1111
+ transcript = candidate / "transcript.jsonl"
1112
+ if transcript.is_file() and not transcript.is_symlink():
1113
+ paths[candidate.name] = transcript
1114
+ for session_id, path in paths.items():
1115
+ if session_id in {".", ".."} or not _SESSION_ID.fullmatch(session_id):
1116
+ continue
1117
+ if not path.is_file() or path.is_symlink():
1118
+ continue
1119
+ try:
1120
+ self.session_dir(session_id)
1121
+ safe_path(self.root, str(path.relative_to(self.root)))
1122
+ except ValueError:
1123
+ continue
1124
+ try:
1125
+ meta = self._read_metadata(session_id)
1126
+ except ValueError:
1127
+ meta = None
1128
+ client = str(meta.get("client") or "") if isinstance(meta, dict) else ""
1129
+ listing = self._session_listing(session_id)
1130
+ message_count = int(listing["message_count"])
1131
+ first_message = str(listing["first_message"])
1132
+ child_ids.update(str(child_id) for child_id in listing["child_ids"])
1133
+ updated = datetime.fromtimestamp(
1134
+ path.stat().st_mtime, tz=timezone.utc
1135
+ ).isoformat()
1136
+ summaries.append(
1137
+ (
1138
+ path.stat().st_mtime,
1139
+ SessionSummary(
1140
+ session_id=session_id,
1141
+ updated_at=updated,
1142
+ message_count=message_count,
1143
+ first_message=first_message,
1144
+ client=client,
1145
+ ),
1146
+ )
1147
+ )
1148
+ return [
1149
+ summary
1150
+ for _, summary in sorted(summaries, key=lambda item: item[0], reverse=True)
1151
+ if summary.session_id not in child_ids
1152
+ ]
1153
+
1154
+ async def save_checkpoint(self, *, checkpoint: Checkpoint) -> None:
1155
+ # The journal already holds the messages; do not dump them every turn.
1156
+ payload = checkpoint.model_dump(exclude={"messages"})
1157
+ payload["metadata"] = {**self.checkpoint_metadata(), **payload["metadata"]}
1158
+ with self._lock, self._session_lock(checkpoint.session_id):
1159
+ entries = self._read_entries(checkpoint.session_id)
1160
+ outgoing: List[Dict[str, Any]] = []
1161
+ if not entries:
1162
+ outgoing.append(self._header(checkpoint.session_id))
1163
+ outgoing.append({"type": "checkpoint", "version": 1, **payload})
1164
+ self._append_entries(checkpoint.session_id, outgoing)
1165
+
1166
+ def checkpoint_goal(self, session_id: str) -> str:
1167
+ """Return the latest saved goal without rebuilding the conversation."""
1168
+ with self._lock:
1169
+ self._cached_entries(session_id)
1170
+ latest = self._indexes[session_id].latest_checkpoint
1171
+ metadata = latest.get("metadata") if isinstance(latest, dict) else None
1172
+ return str(metadata.get("goal") or "") if isinstance(metadata, dict) else ""
1173
+
1174
+ async def load_checkpoint(self, *, session_id: str) -> Optional[Checkpoint]:
1175
+ with self._lock:
1176
+ self._cached_entries(session_id)
1177
+ index = self._indexes[session_id]
1178
+ latest = index.latest_checkpoint
1179
+ if latest is None:
1180
+ return None
1181
+ directory = self.session_dir(session_id)
1182
+ payload = hydrate({key: value for key, value in latest.items() if key != "type"}, directory)
1183
+ payload.setdefault("session_id", session_id)
1184
+ payload["messages"] = [
1185
+ Message.model_validate(hydrate(message, directory)) for message in index.context_payloads()
1186
+ ]
1187
+ return Checkpoint.model_validate(payload)
1188
+
1189
+ async def load_compactions(self, session_id: str) -> List[Dict[str, Any]]:
1190
+ """Return compaction boundaries in transcript order, with hydrated content."""
1191
+ with self._lock:
1192
+ self._read_entries(session_id)
1193
+ index = self._indexes[session_id]
1194
+ directory = self.session_dir(session_id)
1195
+ return [hydrate(entry, directory) for entry in index.compactions]