mycode-coding-agent 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 (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,291 @@
1
+ from collections.abc import Mapping
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import stat
6
+ import tempfile
7
+ from typing import Literal
8
+
9
+
10
+ TrailingRecordPolicy = Literal["error", "ignore", "truncate"]
11
+
12
+
13
+ class FilesystemStorageError(RuntimeError):
14
+ pass
15
+
16
+
17
+ class StorageBoundaryError(FilesystemStorageError):
18
+ pass
19
+
20
+
21
+ class JsonSnapshotError(FilesystemStorageError):
22
+ pass
23
+
24
+
25
+ class JsonLinesError(FilesystemStorageError):
26
+ pass
27
+
28
+
29
+ class JsonLinesCorruptionError(JsonLinesError):
30
+ pass
31
+
32
+
33
+ def ensure_storage_directory(root: str | Path, path: str | Path) -> Path:
34
+ resolved_root = Path(root).resolve(strict=False)
35
+ resolved_root.mkdir(parents=True, exist_ok=True)
36
+ raw = Path(path)
37
+ requested = raw if raw.is_absolute() else resolved_root / raw
38
+ if requested.resolve(strict=False) == resolved_root:
39
+ if not resolved_root.is_dir():
40
+ raise StorageBoundaryError(
41
+ f"Storage root is not a directory: {resolved_root}"
42
+ )
43
+ return resolved_root
44
+ candidate = _bounded_path(resolved_root, path)
45
+ _reject_symlink_components(resolved_root, candidate)
46
+ candidate.mkdir(parents=True, exist_ok=True)
47
+ _reject_symlink_components(resolved_root, candidate)
48
+ if not candidate.is_dir():
49
+ raise StorageBoundaryError(f"Storage path is not a directory: {candidate}")
50
+ return candidate
51
+
52
+
53
+ def write_json_snapshot(
54
+ root: str | Path,
55
+ path: str | Path,
56
+ payload: Mapping[str, object],
57
+ ) -> None:
58
+ target = _prepare_file_path(root, path)
59
+ try:
60
+ content = (
61
+ json.dumps(
62
+ dict(payload),
63
+ ensure_ascii=False,
64
+ allow_nan=False,
65
+ indent=2,
66
+ sort_keys=True,
67
+ )
68
+ + "\n"
69
+ ).encode("utf-8")
70
+ except (TypeError, ValueError) as error:
71
+ raise JsonSnapshotError("JSON snapshot payload is not serializable.") from error
72
+ _atomic_write(target, content)
73
+
74
+
75
+ def read_json_snapshot(root: str | Path, path: str | Path) -> dict[str, object]:
76
+ target = _bounded_file_path(root, path, allow_missing=True)
77
+ try:
78
+ payload = json.loads(target.read_text(encoding="utf-8"))
79
+ except (UnicodeError, json.JSONDecodeError) as error:
80
+ raise JsonSnapshotError(f"Invalid JSON snapshot: {target}") from error
81
+ except OSError as error:
82
+ raise JsonSnapshotError(f"Could not read JSON snapshot: {target}") from error
83
+ if not isinstance(payload, dict):
84
+ raise JsonSnapshotError(f"JSON snapshot must contain an object: {target}")
85
+ return payload
86
+
87
+
88
+ def append_jsonl_record(
89
+ root: str | Path,
90
+ path: str | Path,
91
+ record: Mapping[str, object],
92
+ ) -> None:
93
+ target = _prepare_file_path(root, path)
94
+ try:
95
+ content = (
96
+ json.dumps(
97
+ dict(record),
98
+ ensure_ascii=False,
99
+ allow_nan=False,
100
+ separators=(",", ":"),
101
+ sort_keys=True,
102
+ )
103
+ + "\n"
104
+ ).encode("utf-8")
105
+ except (TypeError, ValueError) as error:
106
+ raise JsonLinesError("JSONL record is not serializable.") from error
107
+
108
+ flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
109
+ if hasattr(os, "O_BINARY"):
110
+ flags |= os.O_BINARY
111
+ try:
112
+ descriptor = os.open(target, flags, 0o600)
113
+ try:
114
+ view = memoryview(content)
115
+ while view:
116
+ written = os.write(descriptor, view)
117
+ if written <= 0:
118
+ raise OSError("append made no progress")
119
+ view = view[written:]
120
+ os.fsync(descriptor)
121
+ finally:
122
+ os.close(descriptor)
123
+ except OSError as error:
124
+ raise JsonLinesError(f"Could not append JSONL record: {target}") from error
125
+
126
+
127
+ def read_jsonl_records(
128
+ root: str | Path,
129
+ path: str | Path,
130
+ *,
131
+ trailing_record: TrailingRecordPolicy = "error",
132
+ ) -> list[dict[str, object]]:
133
+ if trailing_record not in {"error", "ignore", "truncate"}:
134
+ raise ValueError("trailing_record must be 'error', 'ignore', or 'truncate'.")
135
+ target = _bounded_file_path(root, path, allow_missing=True)
136
+ try:
137
+ content = target.read_bytes()
138
+ except OSError as error:
139
+ raise JsonLinesError(f"Could not read JSONL file: {target}") from error
140
+
141
+ lines = content.splitlines(keepends=True)
142
+ has_unterminated_tail = bool(content) and not content.endswith(b"\n")
143
+ records: list[dict[str, object]] = []
144
+ offset = 0
145
+ for index, raw_line in enumerate(lines):
146
+ line_number = index + 1
147
+ try:
148
+ decoded = raw_line.rstrip(b"\r\n").decode("utf-8")
149
+ record = json.loads(decoded)
150
+ except (UnicodeError, json.JSONDecodeError, ValueError) as error:
151
+ is_trailing = index == len(lines) - 1
152
+ is_recoverable_tail = is_trailing and has_unterminated_tail
153
+ if not is_recoverable_tail or trailing_record == "error":
154
+ location = "trailing" if is_trailing else "middle"
155
+ raise JsonLinesCorruptionError(
156
+ f"Invalid {location} JSONL record at line {line_number}: {target}"
157
+ ) from error
158
+ if trailing_record == "truncate":
159
+ _truncate_file(target, offset)
160
+ break
161
+ if not isinstance(record, dict):
162
+ raise JsonLinesCorruptionError(
163
+ f"JSONL record at line {line_number} must be an object: {target}"
164
+ )
165
+ records.append(record)
166
+ offset += len(raw_line)
167
+ return records
168
+
169
+
170
+ def prepare_jsonl_for_append(root: str | Path, path: str | Path) -> list[dict[str, object]]:
171
+ """Normalize under exclusive ownership and return the records already parsed."""
172
+ records = read_jsonl_records(root, path, trailing_record="truncate")
173
+ target = _bounded_file_path(root, path)
174
+ try:
175
+ with target.open("r+b") as stream:
176
+ stream.seek(0, os.SEEK_END)
177
+ if stream.tell():
178
+ stream.seek(-1, os.SEEK_END)
179
+ if stream.read(1) != b"\n":
180
+ stream.write(b"\n")
181
+ stream.flush()
182
+ os.fsync(stream.fileno())
183
+ except OSError as error:
184
+ raise JsonLinesError("Could not prepare JSONL for append.") from error
185
+ return records
186
+
187
+
188
+ def _prepare_file_path(root: str | Path, path: str | Path) -> Path:
189
+ resolved_root = Path(root).resolve(strict=False)
190
+ resolved_root.mkdir(parents=True, exist_ok=True)
191
+ target = _bounded_path(resolved_root, path)
192
+ ensure_storage_directory(resolved_root, target.parent)
193
+ return _bounded_file_path(resolved_root, target, allow_missing=True)
194
+
195
+
196
+ def _bounded_file_path(
197
+ root: str | Path,
198
+ path: str | Path,
199
+ *,
200
+ allow_missing: bool = False,
201
+ ) -> Path:
202
+ resolved_root = Path(root).resolve(strict=False)
203
+ target = _bounded_path(resolved_root, path)
204
+ _reject_symlink_components(resolved_root, target)
205
+ if target.is_symlink():
206
+ raise StorageBoundaryError(f"Storage file cannot be a symlink: {target}")
207
+ if not allow_missing and not target.is_file():
208
+ raise JsonSnapshotError(f"Storage file does not exist: {target}")
209
+ if target.exists() and not target.is_file():
210
+ raise StorageBoundaryError(f"Storage path is not a file: {target}")
211
+ return target
212
+
213
+
214
+ def _bounded_path(root: Path, path: str | Path) -> Path:
215
+ raw = Path(path)
216
+ if ".." in raw.parts:
217
+ raise StorageBoundaryError(f"Storage path contains traversal: {raw}")
218
+ candidate = raw if raw.is_absolute() else root / raw
219
+ resolved = candidate.resolve(strict=False)
220
+ if resolved == root or not resolved.is_relative_to(root):
221
+ raise StorageBoundaryError(f"Storage path escapes its root: {candidate}")
222
+ return candidate
223
+
224
+
225
+ def validate_storage_path(root: str | Path, path: str | Path) -> Path:
226
+ """Check existing components without creating or resolving away links."""
227
+ boundary = Path(root).resolve(strict=False)
228
+ candidate = _bounded_path(boundary, path)
229
+ _reject_symlink_components(boundary, candidate)
230
+ return candidate
231
+
232
+
233
+ def _reject_symlink_components(root: Path, candidate: Path) -> None:
234
+ try:
235
+ relative = candidate.relative_to(root)
236
+ except ValueError as error:
237
+ raise StorageBoundaryError(
238
+ f"Storage path escapes its root: {candidate}"
239
+ ) from error
240
+ current = root
241
+ for part in relative.parts:
242
+ current = current / part
243
+ try:
244
+ info = current.lstat()
245
+ except FileNotFoundError:
246
+ continue
247
+ if current.is_symlink() or getattr(info, "st_file_attributes", 0) & getattr(
248
+ stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0,
249
+ ):
250
+ raise StorageBoundaryError(
251
+ f"Storage path contains a symlink below its root: {current}"
252
+ )
253
+
254
+
255
+ def _atomic_write(path: Path, content: bytes) -> None:
256
+ temporary_path: Path | None = None
257
+ try:
258
+ with tempfile.NamedTemporaryFile(
259
+ mode="wb",
260
+ dir=path.parent,
261
+ prefix=f".{path.name}.",
262
+ suffix=".tmp",
263
+ delete=False,
264
+ ) as temporary:
265
+ temporary.write(content)
266
+ temporary.flush()
267
+ os.fsync(temporary.fileno())
268
+ temporary_path = Path(temporary.name)
269
+ os.replace(temporary_path, path)
270
+ except OSError as error:
271
+ raise JsonSnapshotError(
272
+ f"Could not atomically write JSON snapshot: {path}"
273
+ ) from error
274
+ finally:
275
+ if temporary_path is not None:
276
+ try:
277
+ temporary_path.unlink(missing_ok=True)
278
+ except OSError:
279
+ pass
280
+
281
+
282
+ def _truncate_file(path: Path, size: int) -> None:
283
+ try:
284
+ with path.open("r+b") as stream:
285
+ stream.truncate(size)
286
+ stream.flush()
287
+ os.fsync(stream.fileno())
288
+ except OSError as error:
289
+ raise JsonLinesError(
290
+ f"Could not truncate trailing JSONL record: {path}"
291
+ ) from error
@@ -0,0 +1,208 @@
1
+ from dataclasses import dataclass
2
+ import os
3
+ from pathlib import Path
4
+ import re
5
+
6
+ from mycode.persistence.filesystem import (
7
+ JsonSnapshotError,
8
+ StorageBoundaryError,
9
+ ensure_storage_directory,
10
+ read_json_snapshot,
11
+ write_json_snapshot,
12
+ validate_storage_path,
13
+ )
14
+ from mycode.project import ProjectIdentity
15
+
16
+
17
+ PROJECT_METADATA_VERSION = 1
18
+ PROJECT_HASH_LENGTH = 12
19
+ MAX_PROJECT_BASENAME_CHARS = 80
20
+ PROJECT_COMPONENT_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
21
+ WINDOWS_RESERVED_NAMES = frozenset(
22
+ {"CON", "PRN", "AUX", "NUL"}
23
+ | {f"COM{number}" for number in range(1, 10)}
24
+ | {f"LPT{number}" for number in range(1, 10)}
25
+ )
26
+
27
+
28
+ class ProjectStorageError(RuntimeError):
29
+ pass
30
+
31
+
32
+ class ProjectMetadataError(ProjectStorageError):
33
+ pass
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SessionStorageLayout:
38
+ root: Path
39
+ transcript_path: Path
40
+ meta_path: Path
41
+ compact_path: Path
42
+ artifacts_directory: Path
43
+ subagents_directory: Path
44
+ lock_path: Path
45
+
46
+ def subagent_log_path(self, run_id: str) -> Path:
47
+ validate_storage_component(run_id, field_name="run_id")
48
+ return self.subagents_directory / f"{run_id}.jsonl"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ProjectStorage:
53
+ identity: ProjectIdentity
54
+ projects_root: Path
55
+ project_directory: Path
56
+ metadata_path: Path
57
+ sessions_directory: Path
58
+ locks_directory: Path
59
+
60
+ @classmethod
61
+ def open(
62
+ cls,
63
+ identity: ProjectIdentity,
64
+ *,
65
+ projects_root: str | Path | None = None,
66
+ ) -> "ProjectStorage":
67
+ _validate_project_identity(identity)
68
+ root = Path(
69
+ default_projects_root() if projects_root is None else projects_root
70
+ ).resolve(strict=False)
71
+ root.mkdir(parents=True, exist_ok=True)
72
+ directory_name = project_directory_name(identity)
73
+ try:
74
+ project_directory = ensure_storage_directory(root, directory_name)
75
+ metadata_path = project_directory / "project.json"
76
+ sessions_directory = project_directory / "sessions"
77
+ locks_directory = project_directory / "locks"
78
+ storage = cls(
79
+ identity=identity,
80
+ projects_root=root,
81
+ project_directory=project_directory,
82
+ metadata_path=metadata_path,
83
+ sessions_directory=sessions_directory,
84
+ locks_directory=locks_directory,
85
+ )
86
+ storage._ensure_metadata()
87
+ ensure_storage_directory(project_directory, sessions_directory)
88
+ ensure_storage_directory(project_directory, locks_directory)
89
+ return storage
90
+ except (OSError, JsonSnapshotError, StorageBoundaryError) as error:
91
+ raise ProjectStorageError(
92
+ f"Could not open project storage for {identity.workspace_root}"
93
+ ) from error
94
+
95
+ def session(
96
+ self,
97
+ session_id: str,
98
+ *,
99
+ create: bool = False,
100
+ ) -> SessionStorageLayout:
101
+ validate_storage_component(session_id, field_name="session_id")
102
+ session_root = self.sessions_directory / session_id
103
+ try:
104
+ # Check the project entry before using it as the narrower boundary.
105
+ validate_storage_path(self.projects_root, self.project_directory)
106
+ validate_storage_path(self.project_directory, session_root)
107
+ validate_storage_path(self.project_directory, self.locks_directory)
108
+ if create:
109
+ session_root = ensure_storage_directory(
110
+ self.project_directory,
111
+ session_root,
112
+ )
113
+ artifacts = ensure_storage_directory(session_root, "artifacts")
114
+ subagents = ensure_storage_directory(session_root, "subagents")
115
+ else:
116
+ if session_root.is_symlink():
117
+ raise StorageBoundaryError(
118
+ f"Session directory cannot be a symlink: {session_root}"
119
+ )
120
+ resolved = session_root.resolve(strict=False)
121
+ if not resolved.is_relative_to(self.sessions_directory):
122
+ raise StorageBoundaryError(
123
+ f"Session directory escapes project storage: {session_root}"
124
+ )
125
+ artifacts = session_root / "artifacts"
126
+ subagents = session_root / "subagents"
127
+ for target in (artifacts, subagents):
128
+ validate_storage_path(self.project_directory, target)
129
+ except (OSError, StorageBoundaryError) as error:
130
+ raise ProjectStorageError(
131
+ f"Could not resolve session storage for {session_id!r}"
132
+ ) from error
133
+ return SessionStorageLayout(
134
+ root=session_root,
135
+ transcript_path=session_root / "transcript.jsonl",
136
+ meta_path=session_root / "meta.json",
137
+ compact_path=session_root / "compact.json",
138
+ artifacts_directory=artifacts,
139
+ subagents_directory=subagents,
140
+ lock_path=self.locks_directory / f"{session_id}.lock",
141
+ )
142
+
143
+ def _ensure_metadata(self) -> None:
144
+ expected = {
145
+ "version": PROJECT_METADATA_VERSION,
146
+ "project_key": self.identity.key,
147
+ "workspace_root": str(self.identity.workspace_root),
148
+ }
149
+ if not self.metadata_path.exists():
150
+ write_json_snapshot(
151
+ self.project_directory,
152
+ self.metadata_path,
153
+ expected,
154
+ )
155
+ return
156
+ actual = read_json_snapshot(self.project_directory, self.metadata_path)
157
+ if actual != expected:
158
+ raise ProjectMetadataError(
159
+ "Project metadata does not match the canonical workspace: "
160
+ f"{self.metadata_path}"
161
+ )
162
+
163
+
164
+ def default_projects_root() -> Path:
165
+ return Path.home() / ".mycode" / "projects"
166
+
167
+
168
+ def project_directory_name(identity: ProjectIdentity) -> str:
169
+ _validate_project_identity(identity)
170
+ basename = identity.workspace_root.name or "workspace"
171
+ safe_basename = _safe_project_basename(basename)
172
+ return f"{safe_basename}-{identity.key[:PROJECT_HASH_LENGTH]}"
173
+
174
+
175
+ def _safe_project_basename(value: str) -> str:
176
+ cleaned = "".join(
177
+ "-" if character in '<>:"/\\|?*' or ord(character) < 32 else character
178
+ for character in value
179
+ )
180
+ cleaned = re.sub(r"-+", "-", cleaned).strip(" .-_")
181
+ cleaned = cleaned[:MAX_PROJECT_BASENAME_CHARS].rstrip(" .") or "workspace"
182
+ if cleaned.partition(".")[0].upper() in WINDOWS_RESERVED_NAMES:
183
+ cleaned = f"workspace-{cleaned}"
184
+ return cleaned
185
+
186
+
187
+ def _validate_project_identity(identity: ProjectIdentity) -> None:
188
+ if len(identity.key) != 64 or any(
189
+ character not in "0123456789abcdef" for character in identity.key
190
+ ):
191
+ raise ProjectStorageError("ProjectIdentity.key must be a lowercase SHA-256.")
192
+ canonical = ProjectIdentity.from_workspace(identity.workspace_root)
193
+ if canonical != identity:
194
+ raise ProjectStorageError(
195
+ "ProjectIdentity does not match its canonical workspace path."
196
+ )
197
+
198
+
199
+ def validate_storage_component(value: str, *, field_name: str) -> None:
200
+ """Validate a Session or SubAgent ID using the shared portable path rules."""
201
+ if (
202
+ not PROJECT_COMPONENT_PATTERN.fullmatch(value)
203
+ or value in {".", ".."}
204
+ or value.rstrip(" .") != value
205
+ or value.partition(".")[0].upper() in WINDOWS_RESERVED_NAMES
206
+ or os.path.isabs(value)
207
+ ):
208
+ raise ProjectStorageError(f"Invalid {field_name}: {value!r}")
@@ -0,0 +1,138 @@
1
+ from collections.abc import Iterator
2
+ from contextlib import contextmanager
3
+ from dataclasses import dataclass
4
+ import errno
5
+ import os
6
+ from pathlib import Path
7
+ import time
8
+ from typing import BinaryIO
9
+
10
+
11
+ DEFAULT_SESSION_LOCK_TIMEOUT_SECONDS = 30.0
12
+ DEFAULT_SESSION_LOCK_POLL_SECONDS = 0.05
13
+
14
+
15
+ class SessionLockError(RuntimeError):
16
+ pass
17
+
18
+
19
+ class SessionLockTimeoutError(SessionLockError):
20
+ pass
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SessionLifecycleLock:
25
+ """Exclusive OS lock held for the complete lifetime of one session owner."""
26
+
27
+ path: Path
28
+ timeout_seconds: float = DEFAULT_SESSION_LOCK_TIMEOUT_SECONDS
29
+ poll_seconds: float = DEFAULT_SESSION_LOCK_POLL_SECONDS
30
+
31
+ def __post_init__(self) -> None:
32
+ if self.timeout_seconds < 0:
33
+ raise ValueError("timeout_seconds must be at least 0.")
34
+ if self.poll_seconds <= 0:
35
+ raise ValueError("poll_seconds must be above 0.")
36
+ raw_path = Path(self.path)
37
+ if raw_path.is_symlink():
38
+ raise SessionLockError("Session lifecycle lock file cannot be a symlink.")
39
+ object.__setattr__(
40
+ self,
41
+ "path",
42
+ raw_path.parent.resolve(strict=False) / raw_path.name,
43
+ )
44
+
45
+ @contextmanager
46
+ def acquire(self) -> Iterator[None]:
47
+ with _acquire_lifecycle_lock(
48
+ self.path,
49
+ timeout_seconds=self.timeout_seconds,
50
+ poll_seconds=self.poll_seconds,
51
+ ):
52
+ yield
53
+
54
+
55
+ @contextmanager
56
+ def _acquire_lifecycle_lock(
57
+ path: Path,
58
+ *,
59
+ timeout_seconds: float,
60
+ poll_seconds: float,
61
+ ) -> Iterator[None]:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ if path.is_symlink():
64
+ raise SessionLockError("Session lifecycle lock file cannot be a symlink.")
65
+ with path.open("a+b") as stream:
66
+ _ensure_lock_byte(stream)
67
+ deadline = time.monotonic() + timeout_seconds
68
+ while True:
69
+ try:
70
+ _lock_stream(stream)
71
+ break
72
+ except OSError as error:
73
+ if not _is_lock_contention(error):
74
+ raise SessionLockError(
75
+ f"Failed to acquire session lifecycle lock: {path}"
76
+ ) from error
77
+ if time.monotonic() >= deadline:
78
+ raise SessionLockTimeoutError(
79
+ "Timed out waiting for another session lifecycle owner to finish."
80
+ ) from error
81
+ time.sleep(
82
+ min(
83
+ poll_seconds,
84
+ max(0.0, deadline - time.monotonic()),
85
+ )
86
+ )
87
+
88
+ try:
89
+ yield
90
+ finally:
91
+ try:
92
+ _unlock_stream(stream)
93
+ except OSError as error:
94
+ raise SessionLockError(
95
+ f"Failed to release session lifecycle lock: {path}"
96
+ ) from error
97
+
98
+
99
+ def _ensure_lock_byte(stream: BinaryIO) -> None:
100
+ stream.seek(0, os.SEEK_END)
101
+ if stream.tell() == 0:
102
+ stream.write(b"\0")
103
+ stream.flush()
104
+ os.fsync(stream.fileno())
105
+
106
+
107
+ def _lock_stream(stream: BinaryIO) -> None:
108
+ stream.seek(0)
109
+ if os.name == "nt":
110
+ import msvcrt
111
+
112
+ msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
113
+ return
114
+
115
+ import fcntl
116
+
117
+ fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
118
+
119
+
120
+ def _unlock_stream(stream: BinaryIO) -> None:
121
+ stream.seek(0)
122
+ if os.name == "nt":
123
+ import msvcrt
124
+
125
+ msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
126
+ return
127
+
128
+ import fcntl
129
+
130
+ fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
131
+
132
+
133
+ def _is_lock_contention(error: OSError) -> bool:
134
+ return error.errno in {
135
+ errno.EACCES,
136
+ errno.EAGAIN,
137
+ errno.EDEADLK,
138
+ } or getattr(error, "winerror", None) in {33, 36}