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,33 @@
1
+ """Plain-text body of the /addons command (TUI and stdio)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from core_harness.extensions import AddonFailure
6
+
7
+ from coding_agent.addons.manager.bridge import AddonSurface
8
+
9
+
10
+ def addons_report(surface: AddonSurface, hook_failures: tuple[AddonFailure, ...] = ()) -> str:
11
+ exposed_tools = surface.tool_names
12
+ exposed_commands = {item.name for item in surface.commands}
13
+ lines = ["Loaded addons:"]
14
+ for addon in surface.snapshot.loaded:
15
+ tools = ", ".join(tool.name for tool in addon.tools if tool.name in exposed_tools) or "none"
16
+ commands = ", ".join(
17
+ f"/{item.name}" for item in addon.commands if item.name in exposed_commands
18
+ ) or "none"
19
+ lines.append(f" {addon.name} {addon.version} · tools: {tools} · commands: {commands}")
20
+ if not surface.snapshot.loaded:
21
+ lines.append(" none")
22
+ failures = surface.snapshot.failed + hook_failures
23
+ lines.append("Failed addons:")
24
+ lines.extend(f" {item.name} · {item.phase} · {item.error}" for item in failures)
25
+ if not failures:
26
+ lines.append(" none")
27
+ if surface.notices:
28
+ lines.append("Notices:")
29
+ lines.extend(f" {notice}" for notice in surface.notices)
30
+ return "\n".join(lines)
31
+
32
+
33
+ __all__ = ["addons_report"]
@@ -0,0 +1,76 @@
1
+ """The live user-addon state of one agent.
2
+
3
+ Holds the current ``AddonSurface``, forwards sink events to addon
4
+ handlers, and runs the /reload swap: build the new snapshot, dispose the
5
+ old addons, then swap. A reload asked for during a run waits for the run
6
+ to end.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ from typing import Any, Callable, Collection, Mapping
13
+
14
+ from core_harness.extensions import AddonFailure, dispatch_event, dispose_addons
15
+
16
+ from coding_agent.addons.manager.bridge import AddonSurface, build_surface
17
+ from coding_agent.addons.manager.discovery import load_user_addons
18
+ from coding_agent.addons.manager.tool_changes import tool_change_notice
19
+
20
+ Loader = Callable[[Collection[str]], Any]
21
+
22
+
23
+ class UserAddonRuntime:
24
+ def __init__(
25
+ self,
26
+ *,
27
+ builtin_tool_names: Collection[str],
28
+ reserved_names: Collection[str] = (),
29
+ loader: Loader = load_user_addons,
30
+ ) -> None:
31
+ self.builtin_tool_names = frozenset(builtin_tool_names)
32
+ self.reserved_names = frozenset(reserved_names)
33
+ self.loader = loader
34
+ self.surface: AddonSurface = self.build()
35
+ self.hook_failures: tuple[AddonFailure, ...] = ()
36
+ self.reload_pending = False
37
+
38
+ def build(self) -> AddonSurface:
39
+ return build_surface(self.loader(self.reserved_names), builtin_tool_names=self.builtin_tool_names)
40
+
41
+ def on_event(self, event: str, payload: Mapping[str, Any]) -> None:
42
+ """Sink listener. Never raises; failures are kept for /addons."""
43
+ failures = dispatch_event(self.surface.snapshot, event, payload)
44
+ if failures:
45
+ self.hook_failures = (self.hook_failures + failures)[-20:]
46
+
47
+ def dispose(self) -> tuple[AddonFailure, ...]:
48
+ failures = dispose_addons(self.surface.snapshot.loaded)
49
+ self.hook_failures = (self.hook_failures + failures)[-20:]
50
+ return failures
51
+
52
+ def swap(self, surface: AddonSurface) -> str:
53
+ """Dispose the current addons, install ``surface`` and describe the tool change."""
54
+ before = self.surface.tool_names
55
+ self.dispose()
56
+ self.surface = surface
57
+ return tool_change_notice(before, surface.tool_names)
58
+
59
+ async def run_command(self, name: str, argument: str) -> str | None:
60
+ """Run an addon command. ``None`` when no addon owns ``name``."""
61
+ command = self.surface.command(name)
62
+ if command is None:
63
+ return None
64
+ try:
65
+ reply = command.handler(argument)
66
+ if inspect.isawaitable(reply):
67
+ reply = await reply
68
+ except Exception as exc: # noqa: BLE001 - addon code is untrusted
69
+ self.hook_failures = (self.hook_failures + (
70
+ AddonFailure(command.name, "command", f"{type(exc).__name__}: {exc}"),
71
+ ))[-20:]
72
+ return f"/{name} failed · {type(exc).__name__}: {exc}"
73
+ return str(reply)
74
+
75
+
76
+ __all__ = ["UserAddonRuntime"]
@@ -0,0 +1,16 @@
1
+ """The notice shown after /reload swaps user addons."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Collection
6
+
7
+
8
+ def tool_change_notice(before: Collection[str], after: Collection[str]) -> str:
9
+ added = len(set(after) - set(before))
10
+ removed = len(set(before) - set(after))
11
+ if not added and not removed:
12
+ return "no tool changes"
13
+ return f"tools changed (+{added}/-{removed}): prompt cache will rebuild"
14
+
15
+
16
+ __all__ = ["tool_change_notice"]
@@ -0,0 +1,41 @@
1
+ """Coding-agent persistence backends."""
2
+
3
+ from coding_agent.addons.persistence.collection import (
4
+ COLLECTABLE_EVENT_TYPES,
5
+ TERMINAL_EVENT_TYPES,
6
+ apply_collection,
7
+ collectable_from_events,
8
+ collected_keys,
9
+ event_key,
10
+ is_collectable_event,
11
+ is_collected,
12
+ )
13
+ from coding_agent.addons.persistence.active import (
14
+ ActiveSession,
15
+ active_dir,
16
+ active_session_ids,
17
+ list_active,
18
+ register_active,
19
+ release_active,
20
+ )
21
+ from coding_agent.addons.persistence.jsonl import JsonlPersistence, SessionSummary, sessions_dir
22
+
23
+ __all__ = [
24
+ "ActiveSession",
25
+ "COLLECTABLE_EVENT_TYPES",
26
+ "JsonlPersistence",
27
+ "SessionSummary",
28
+ "TERMINAL_EVENT_TYPES",
29
+ "active_dir",
30
+ "active_session_ids",
31
+ "apply_collection",
32
+ "collectable_from_events",
33
+ "collected_keys",
34
+ "event_key",
35
+ "is_collectable_event",
36
+ "is_collected",
37
+ "list_active",
38
+ "register_active",
39
+ "release_active",
40
+ "sessions_dir",
41
+ ]
@@ -0,0 +1,132 @@
1
+ """Live registry of Symphony processes that currently own a session.
2
+
3
+ Resume should not offer a conversation another process is still using. Each
4
+ running agent writes one small JSON file under ``~/.symphony/active`` and
5
+ removes it on a clean exit. A file whose process is gone is stale and ignored.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ def active_dir() -> Path:
18
+ """Directory that holds one registry file per live Symphony process."""
19
+ target = (Path.home() / ".symphony" / "active").expanduser()
20
+ target.mkdir(parents=True, exist_ok=True)
21
+ return target
22
+
23
+
24
+ def _path(pid: int) -> Path:
25
+ return active_dir() / f"{pid}.json"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ActiveSession:
30
+ """One Symphony process and the session it currently owns."""
31
+
32
+ session_id: str
33
+ pid: int
34
+ kind: str
35
+ workspace: str
36
+ model_id: str = ""
37
+ label: str = ""
38
+
39
+ def as_dict(self) -> dict[str, Any]:
40
+ return {
41
+ "session_id": self.session_id,
42
+ "pid": self.pid,
43
+ "kind": self.kind,
44
+ "workspace": self.workspace,
45
+ "model_id": self.model_id,
46
+ "label": self.label,
47
+ }
48
+
49
+
50
+ def register_active(
51
+ session_id: str,
52
+ *,
53
+ workspace: str | Path,
54
+ kind: str,
55
+ model_id: str = "",
56
+ label: str = "",
57
+ pid: int | None = None,
58
+ ) -> ActiveSession:
59
+ """Record that this process owns ``session_id``. Replaces any prior record."""
60
+ record = ActiveSession(
61
+ session_id=session_id,
62
+ pid=os.getpid() if pid is None else pid,
63
+ kind=kind,
64
+ workspace=str(workspace),
65
+ model_id=model_id,
66
+ label=label,
67
+ )
68
+ path = _path(record.pid)
69
+ temporary = path.with_suffix(".json.tmp")
70
+ temporary.write_text(json.dumps(record.as_dict()), encoding="utf-8")
71
+ temporary.replace(path)
72
+ return record
73
+
74
+
75
+ def release_active(pid: int | None = None) -> None:
76
+ """Drop this process's registry file. Missing files are fine."""
77
+ path = _path(os.getpid() if pid is None else pid)
78
+ try:
79
+ path.unlink()
80
+ except FileNotFoundError:
81
+ return
82
+
83
+
84
+ def _load(path: Path) -> ActiveSession | None:
85
+ try:
86
+ payload = json.loads(path.read_text(encoding="utf-8"))
87
+ except (OSError, json.JSONDecodeError):
88
+ return None
89
+ if not isinstance(payload, dict):
90
+ return None
91
+ session_id = str(payload.get("session_id") or "")
92
+ try:
93
+ pid = int(payload.get("pid") or 0)
94
+ except (TypeError, ValueError):
95
+ return None
96
+ if not session_id or pid <= 0:
97
+ return None
98
+ return ActiveSession(
99
+ session_id=session_id,
100
+ pid=pid,
101
+ kind=str(payload.get("kind") or "agent"),
102
+ workspace=str(payload.get("workspace") or ""),
103
+ model_id=str(payload.get("model_id") or ""),
104
+ label=str(payload.get("label") or ""),
105
+ )
106
+
107
+
108
+ def list_active(*, include_dead: bool = False) -> list[ActiveSession]:
109
+ """Return live sessions, dropping registry files whose process has exited."""
110
+ from coding_agent.resources.usage import process_alive
111
+
112
+ records: list[ActiveSession] = []
113
+ for path in sorted(active_dir().glob("*.json")):
114
+ record = _load(path)
115
+ if record is None:
116
+ continue
117
+ if process_alive(record.pid):
118
+ records.append(record)
119
+ continue
120
+ if include_dead:
121
+ records.append(record)
122
+ continue
123
+ try:
124
+ path.unlink()
125
+ except OSError:
126
+ pass
127
+ return records
128
+
129
+
130
+ def active_session_ids() -> set[str]:
131
+ """Session ids currently owned by a live Symphony process."""
132
+ return {record.session_id for record in list_active()}
@@ -0,0 +1,110 @@
1
+ """Safe session artifact paths and atomic publication helpers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def safe_path(root: Path, *parts: str) -> Path:
12
+ """Reject symlinks, including links that happen to point inside the root."""
13
+ path = root
14
+ if root.is_symlink():
15
+ raise ValueError(f"unsafe artifact directory: {root}")
16
+ for part in parts:
17
+ component = Path(part)
18
+ if component.is_absolute() or any(p in {".", ".."} for p in component.parts):
19
+ raise ValueError(f"unsafe artifact path: {part!r}")
20
+ for name in component.parts:
21
+ path = path / name
22
+ if path.is_symlink():
23
+ raise ValueError(f"unsafe artifact symlink: {path}")
24
+ if not path.resolve().is_relative_to(root.resolve()):
25
+ raise ValueError(f"artifact path escapes root: {path}")
26
+ return path
27
+
28
+
29
+ def fsync_dir(directory: Path) -> None:
30
+ """Make a rename or create durable. The file fsync alone does not."""
31
+ fd = os.open(directory, os.O_RDONLY)
32
+ try:
33
+ os.fsync(fd)
34
+ finally:
35
+ os.close(fd)
36
+
37
+
38
+ def publish(path: Path, payload: bytes) -> None:
39
+ """Publish a complete file using a unique sibling temporary file."""
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ if path.is_symlink():
42
+ raise ValueError(f"unsafe artifact symlink: {path}")
43
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
44
+ replaced = False
45
+ try:
46
+ with os.fdopen(fd, "wb") as handle:
47
+ handle.write(payload)
48
+ handle.flush()
49
+ os.fsync(handle.fileno())
50
+ os.replace(temporary, path)
51
+ replaced = True
52
+ fsync_dir(path.parent)
53
+ finally:
54
+ if not replaced:
55
+ Path(temporary).unlink(missing_ok=True)
56
+
57
+
58
+ def append_bytes(path: Path, payload: bytes) -> None:
59
+ """Append one durable record. The line is fsynced before this returns.
60
+
61
+ The directory is fsynced only when this call created the file: appending
62
+ to an existing file adds no directory entry, and the file fsync already
63
+ makes the new length durable.
64
+ """
65
+ if not payload:
66
+ return
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ if path.is_symlink():
69
+ raise ValueError(f"unsafe artifact symlink: {path}")
70
+ created = not path.exists()
71
+ flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
72
+ if hasattr(os, "O_NOFOLLOW"):
73
+ flags |= os.O_NOFOLLOW
74
+ fd = os.open(path, flags, 0o644)
75
+ try:
76
+ view = payload
77
+ while view:
78
+ written = os.write(fd, view)
79
+ if written <= 0:
80
+ raise OSError(f"short write to {path}")
81
+ view = view[written:]
82
+ os.fsync(fd)
83
+ finally:
84
+ os.close(fd)
85
+ if created:
86
+ fsync_dir(path.parent)
87
+
88
+
89
+ def _snapshot_bytes(payload: dict[str, Any]) -> bytes:
90
+ return (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
91
+
92
+
93
+ def snapshot(path: Path, payload: dict[str, Any]) -> None:
94
+ publish(path, _snapshot_bytes(payload))
95
+
96
+
97
+ def refresh_snapshot(path: Path, payload: dict[str, Any]) -> None:
98
+ """Publish ``payload`` only when the file is missing or holds other bytes.
99
+
100
+ Re-reading a journal repairs artifacts a crash left stale. When they are
101
+ already current this costs one small read instead of two fsyncs.
102
+ """
103
+ encoded = _snapshot_bytes(payload)
104
+ if not path.is_symlink():
105
+ try:
106
+ if path.read_bytes() == encoded:
107
+ return
108
+ except OSError:
109
+ pass
110
+ publish(path, encoded)
@@ -0,0 +1,166 @@
1
+ """Mark mid-run control-plane events as collected after a run finishes.
2
+
3
+ The JSONL journal is append-only, so collection never rewrites historical
4
+ entries. A later ``collected`` record points at the original ``(run_id, seq)``
5
+ and stays sticky: once tagged, an event remains collected on every later load.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Iterable, Mapping, Sequence
11
+
12
+ # Events that belong to the work between the user message and the final
13
+ # assistant reply. Token-delta types are never persisted, so they are omitted.
14
+ COLLECTABLE_EVENT_TYPES = frozenset(
15
+ {
16
+ "turn_started",
17
+ "turn_completed",
18
+ "model_retry_scheduled",
19
+ "tool_call_started",
20
+ "tool_execution_started",
21
+ "tool_execution_completed",
22
+ "usage",
23
+ "context",
24
+ "context_warning",
25
+ "compaction_started",
26
+ "compaction_completed",
27
+ "message_injected",
28
+ "waiting_for_children",
29
+ "agent_spawned",
30
+ "agent_completed",
31
+ "agent_failed",
32
+ }
33
+ )
34
+
35
+ TERMINAL_EVENT_TYPES = frozenset(
36
+ {
37
+ "run_completed",
38
+ "run_failed",
39
+ "run_cancelled",
40
+ "run_limit_exceeded",
41
+ }
42
+ )
43
+
44
+
45
+ def is_collected(payload: Mapping[str, Any] | None) -> bool:
46
+ """True when a payload (or a later overlay) has ``collected: true``."""
47
+ return bool(payload and payload.get("collected") is True)
48
+
49
+
50
+ def event_key(payload: Mapping[str, Any] | None) -> tuple[str, int] | None:
51
+ """Stable identity for a stamped harness event."""
52
+ if not payload:
53
+ return None
54
+ run_id = payload.get("run_id")
55
+ seq = payload.get("seq")
56
+ if run_id and isinstance(seq, int):
57
+ return (str(run_id), seq)
58
+ return None
59
+
60
+
61
+ def is_collectable_event(event_type: str, payload: Mapping[str, Any] | None = None) -> bool:
62
+ """True for mid-run events that the TUI folds after the final reply."""
63
+ if event_type not in COLLECTABLE_EVENT_TYPES:
64
+ return False
65
+ if is_collected(payload):
66
+ return False
67
+ return True
68
+
69
+
70
+ def collected_keys(events: Iterable[tuple[str, Mapping[str, Any]]]) -> set[tuple[str, int]]:
71
+ """Return every ``(run_id, seq)`` that has been tagged collected."""
72
+ keys: set[tuple[str, int]] = set()
73
+ for event_type, payload in events:
74
+ key = event_key(payload)
75
+ if key is None:
76
+ continue
77
+ if event_type == "collected" or is_collected(payload):
78
+ keys.add(key)
79
+ return keys
80
+
81
+
82
+ def apply_collection(
83
+ events: Sequence[tuple[str, Mapping[str, Any]]],
84
+ *,
85
+ collected: Iterable[tuple[str, int]] | None = None,
86
+ ) -> list[tuple[str, dict[str, Any]]]:
87
+ """Overlay ``collected: true`` onto events that have been tagged.
88
+
89
+ Overlay records (``event_type == "collected"``) are consumed and not
90
+ returned; the original event keeps its type and gains the flag.
91
+ """
92
+ tagged = set(collected) if collected is not None else collected_keys(events)
93
+ applied: list[tuple[str, dict[str, Any]]] = []
94
+ for event_type, payload in events:
95
+ if event_type == "collected":
96
+ continue
97
+ outgoing = dict(payload)
98
+ key = event_key(outgoing)
99
+ if key is not None and key in tagged:
100
+ outgoing["collected"] = True
101
+ applied.append((event_type, outgoing))
102
+ return applied
103
+
104
+
105
+ def collectable_from_events(
106
+ events: Sequence[tuple[str, Mapping[str, Any]]],
107
+ *,
108
+ run_id: str | None = None,
109
+ ) -> list[tuple[str, int]]:
110
+ """Keys of mid-run events that should be collected for a finished run.
111
+
112
+ Collection covers events from ``run_started`` (exclusive) up to the
113
+ terminal run event (exclusive). The user prompt and the final assistant
114
+ output stay uncollected so the TUI can keep them as visible transcript
115
+ rows. When ``run_id`` is omitted, the most recent terminal event's run
116
+ is collected.
117
+ """
118
+ if run_id is None:
119
+ run_id = _latest_terminal_run_id(events)
120
+ if not run_id:
121
+ return []
122
+ started = False
123
+ keys: list[tuple[str, int]] = []
124
+ seen: set[tuple[str, int]] = set()
125
+ for event_type, payload in events:
126
+ payload_run = str(payload.get("run_id") or "")
127
+ if payload_run != run_id:
128
+ continue
129
+ if event_type == "run_started":
130
+ started = True
131
+ continue
132
+ if not started:
133
+ continue
134
+ if event_type in TERMINAL_EVENT_TYPES:
135
+ break
136
+ if not is_collectable_event(event_type, payload):
137
+ continue
138
+ key = event_key(payload)
139
+ if key is None or key in seen:
140
+ continue
141
+ seen.add(key)
142
+ keys.append(key)
143
+ return keys
144
+
145
+
146
+ def _latest_terminal_run_id(
147
+ events: Sequence[tuple[str, Mapping[str, Any]]],
148
+ ) -> str | None:
149
+ for event_type, payload in reversed(list(events)):
150
+ if event_type in TERMINAL_EVENT_TYPES:
151
+ run_id = payload.get("run_id")
152
+ if run_id:
153
+ return str(run_id)
154
+ return None
155
+
156
+
157
+ __all__ = [
158
+ "COLLECTABLE_EVENT_TYPES",
159
+ "TERMINAL_EVENT_TYPES",
160
+ "apply_collection",
161
+ "collectable_from_events",
162
+ "collected_keys",
163
+ "event_key",
164
+ "is_collectable_event",
165
+ "is_collected",
166
+ ]
@@ -0,0 +1,30 @@
1
+ """Fail closed when resuming sessions saved with retired execution modes."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from uuid import UUID
6
+
7
+ from coding_agent.config import symphony_dir
8
+ from coding_agent.addons.persistence.artifacts import safe_path
9
+
10
+
11
+ def validate_session_workflow(directory: Path | None, session_id: str) -> None:
12
+ """Check before any model/tool execution, including direct and headless runs.
13
+
14
+ Never rewrite saved state or delete the user's archived plans. The bundle
15
+ takes precedence over the legacy protocol state, as in protocol loading.
16
+ """
17
+ path = safe_path(directory, "state.json") if directory is not None else None
18
+ if path is None or not path.exists():
19
+ try:
20
+ path = symphony_dir() / "protocols" / f"{UUID(session_id)}.json"
21
+ except ValueError:
22
+ return
23
+ if not path.exists():
24
+ return
25
+ data = json.loads(path.read_text(encoding="utf-8"))
26
+ if not isinstance(data, dict) or data.get("mode", "build") != "build":
27
+ raise ValueError(
28
+ "This session was saved with a removed or unsupported mode. "
29
+ "It cannot be resumed safely; explicitly start a fresh session."
30
+ )