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,503 @@
1
+ """Project-scoped persistence; writable handles own a lifecycle OS lock."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable, Iterable, Iterator
5
+ from contextlib import contextmanager, ExitStack
6
+ from dataclasses import dataclass, replace
7
+ from datetime import datetime, timezone
8
+ import json
9
+ from pathlib import Path
10
+ import shutil
11
+ from threading import RLock
12
+ from typing import Literal
13
+ from uuid import uuid4
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
16
+
17
+ from mycode.agent.events import AgentToolCall
18
+ from mycode.context.compact import CompactState, DEFAULT_COMPACT_FAILURE_COOLDOWN_MESSAGES
19
+ from mycode.conversation import Conversation
20
+ from mycode.persistence.filesystem import (
21
+ FilesystemStorageError, JsonSnapshotError, append_jsonl_record, prepare_jsonl_for_append,
22
+ read_json_snapshot, read_jsonl_records, write_json_snapshot,
23
+ )
24
+ from mycode.messages import Message
25
+ from mycode.project import ProjectIdentity
26
+ from mycode.persistence.project_storage import ProjectStorage, ProjectStorageError, validate_storage_component
27
+ from mycode.persistence.session_lock import (
28
+ SessionLifecycleLock, SessionLockError, SessionLockTimeoutError,
29
+ )
30
+
31
+ DEFAULT_SESSION_TITLE = "New session"
32
+ MAX_SESSION_TITLE_CHARS = 200
33
+ MAX_COMPACT_STATE_JSON_CHARS = 20000
34
+
35
+
36
+ class SessionStoreError(RuntimeError):
37
+ pass
38
+
39
+
40
+ class SessionNotFoundError(SessionStoreError):
41
+ pass
42
+
43
+
44
+ class SessionInUseError(SessionStoreError):
45
+ pass
46
+
47
+
48
+ class SessionDataError(SessionStoreError):
49
+ pass
50
+
51
+
52
+ class CompactStateDataError(SessionDataError):
53
+ """Only invalid Compact snapshot data; never session or boundary failures."""
54
+
55
+
56
+ class _Metadata(BaseModel):
57
+ model_config = ConfigDict(extra="forbid", strict=True)
58
+ version: Literal[1] = 1
59
+ session_id: str
60
+ title: str = Field(min_length=1, max_length=MAX_SESSION_TITLE_CHARS)
61
+ created_at: datetime
62
+ updated_at: datetime
63
+ last_terminal_state: Literal["closed", "interrupted"] = "interrupted"
64
+
65
+
66
+ class _ToolCall(BaseModel):
67
+ model_config = ConfigDict(extra="forbid", strict=True)
68
+ id: str = Field(min_length=1)
69
+ name: str = Field(min_length=1)
70
+ arguments: dict[str, object]
71
+
72
+
73
+ class _TranscriptRecord(BaseModel):
74
+ model_config = ConfigDict(extra="forbid", strict=True)
75
+ version: Literal[1] = 1
76
+ sequence: int = Field(ge=1)
77
+ role: Literal["user", "assistant", "tool"]
78
+ content: str
79
+ tool_calls: list[_ToolCall] = Field(default_factory=list)
80
+ tool_call_id: str | None = None
81
+ reasoning_content: str | None = None
82
+ reasoning_state: Literal["absent", "present_empty", "present_nonempty"] = "absent"
83
+
84
+ @model_validator(mode="after")
85
+ def validate_message(self) -> "_TranscriptRecord":
86
+ if self.tool_calls and self.role != "assistant":
87
+ raise ValueError("Only assistant messages can contain tool calls.")
88
+ if (self.role == "tool") != bool(self.tool_call_id):
89
+ raise ValueError("Only tool results require tool_call_id.")
90
+ if self.reasoning_state != "absent" and (
91
+ self.role != "assistant" or not self.tool_calls
92
+ ):
93
+ raise ValueError("Reasoning requires an assistant tool call.")
94
+ if self.reasoning_state == "present_nonempty":
95
+ if not self.reasoning_content:
96
+ raise ValueError("Nonempty reasoning required.")
97
+ elif self.reasoning_content is not None:
98
+ raise ValueError("Inconsistent reasoning content.")
99
+ return self
100
+
101
+ def message(self) -> Message:
102
+ return Message(
103
+ role=self.role, content=self.content,
104
+ tool_calls=tuple(AgentToolCall(
105
+ id=c.id, name=c.name, arguments=c.arguments,
106
+ ) for c in self.tool_calls),
107
+ tool_call_id=self.tool_call_id,
108
+ reasoning_content=self.reasoning_content,
109
+ reasoning_state=self.reasoning_state,
110
+ )
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class SessionRecord:
115
+ id: str
116
+ project_key: str
117
+ workspace_root: Path
118
+ title: str
119
+ status: str
120
+ created_at: datetime
121
+ updated_at: datetime
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class CompactStateLoadResult:
126
+ state: CompactState
127
+ recovered_invalid_state: bool = False
128
+
129
+
130
+ @contextmanager
131
+ def _storage_errors() -> Iterator[None]:
132
+ try:
133
+ yield
134
+ except SessionLockTimeoutError as error:
135
+ raise SessionInUseError("Session is in use by another owner.") from error
136
+ except (FilesystemStorageError, ProjectStorageError, ValidationError) as error:
137
+ raise SessionDataError("Invalid session filesystem data.") from error
138
+ except (OSError, SessionLockError) as error:
139
+ raise SessionStoreError("Session filesystem operation failed.") from error
140
+
141
+
142
+ class SessionStore:
143
+ def __init__(
144
+ self, projects_root: str | Path | None = None, *,
145
+ now: Callable[[], datetime] | None = None,
146
+ ) -> None:
147
+ self.projects_root = projects_root
148
+ self._now = now or (lambda: datetime.now(timezone.utc))
149
+
150
+ def project_storage(self, project: ProjectIdentity) -> ProjectStorage:
151
+ with _storage_errors():
152
+ return ProjectStorage.open(project, projects_root=self.projects_root)
153
+
154
+ def _metadata(self, storage: ProjectStorage, session_id: str) -> _Metadata:
155
+ layout = storage.session(session_id)
156
+ if not layout.root.exists():
157
+ raise SessionNotFoundError(f"Session not found: {session_id}")
158
+ # lstat distinguishes an absent commit marker from a dangling link.
159
+ try:
160
+ layout.meta_path.lstat()
161
+ except FileNotFoundError:
162
+ raise SessionNotFoundError(f"Session not committed: {session_id}") from None
163
+ data = read_json_snapshot(storage.project_directory, layout.meta_path)
164
+ meta = _Metadata.model_validate_json(json.dumps(data))
165
+ if meta.session_id != session_id:
166
+ raise SessionDataError("Session metadata identity mismatch.")
167
+ if any(t.tzinfo is None for t in (meta.created_at, meta.updated_at)):
168
+ raise SessionDataError("Session timestamps require timezone.")
169
+ return meta
170
+
171
+ def _record(self, project: ProjectIdentity, meta: _Metadata) -> SessionRecord:
172
+ return SessionRecord(
173
+ id=meta.session_id, project_key=project.key,
174
+ workspace_root=project.workspace_root, title=meta.title,
175
+ status=meta.last_terminal_state,
176
+ created_at=meta.created_at, updated_at=meta.updated_at,
177
+ )
178
+
179
+ def get_session(self, project: ProjectIdentity, session_id: str) -> SessionRecord | None:
180
+ with _storage_errors():
181
+ storage = self.project_storage(project)
182
+ try:
183
+ meta = self._metadata(storage, session_id)
184
+ except SessionNotFoundError:
185
+ return None
186
+ record = self._record(project, meta)
187
+ try:
188
+ with SessionLifecycleLock(
189
+ storage.session(session_id).lock_path, timeout_seconds=0,
190
+ ).acquire():
191
+ pass
192
+ except SessionLockTimeoutError:
193
+ record = replace(record, status="active")
194
+ return record
195
+
196
+ def list_sessions(self, project: ProjectIdentity, *, limit: int = 10) -> list[SessionRecord]:
197
+ if limit < 1:
198
+ raise ValueError("limit must be positive.")
199
+ with _storage_errors():
200
+ storage = self.project_storage(project)
201
+ records = []
202
+ for path in storage.sessions_directory.iterdir():
203
+ try:
204
+ validate_storage_component(path.name, field_name="session_id")
205
+ except ProjectStorageError:
206
+ continue
207
+ if not path.is_dir() and not path.is_symlink():
208
+ continue
209
+ record = self.get_session(project, path.name)
210
+ if record is not None:
211
+ records.append(record)
212
+ return sorted(records, key=lambda r: (r.updated_at, r.id), reverse=True)[:limit]
213
+
214
+ @contextmanager
215
+ def open_session(
216
+ self, project: ProjectIdentity, session_id: str | None = None, *,
217
+ create: bool = False, title: str = DEFAULT_SESSION_TITLE,
218
+ ) -> Iterator["WritableSession"]:
219
+ if session_id is None and not create:
220
+ raise SessionNotFoundError("Session id is required for resume.")
221
+ identifier = str(uuid4()) if session_id is None else session_id
222
+ with ExitStack() as ownership:
223
+ with _storage_errors():
224
+ storage = self.project_storage(project)
225
+ layout = storage.session(identifier)
226
+ ownership.enter_context(SessionLifecycleLock(
227
+ layout.lock_path, timeout_seconds=0,
228
+ ).acquire())
229
+ if create:
230
+ if layout.root.exists():
231
+ try:
232
+ self._metadata(storage, identifier)
233
+ except SessionNotFoundError:
234
+ _validate_deletion_tree(layout.root)
235
+ shutil.rmtree(layout.root)
236
+ else:
237
+ raise SessionDataError("Session already exists.")
238
+ meta = _Metadata(
239
+ session_id=identifier, title=_title(title),
240
+ created_at=self._now(), updated_at=self._now(),
241
+ )
242
+ try:
243
+ layout = storage.session(identifier, create=True)
244
+ layout.transcript_path.touch(exist_ok=False)
245
+ # Publish last: all prior data is an invisible orphan until this succeeds.
246
+ write_json_snapshot(
247
+ storage.project_directory, layout.meta_path, meta.model_dump(mode="json"),
248
+ )
249
+ except BaseException:
250
+ try:
251
+ if not layout.meta_path.exists() and layout.root.exists():
252
+ _validate_deletion_tree(layout.root)
253
+ shutil.rmtree(layout.root)
254
+ except (OSError, SessionDataError):
255
+ pass # An uncommitted orphan remains invisible and can be retried.
256
+ raise
257
+ else:
258
+ meta = self._metadata(storage, identifier)
259
+ records = prepare_jsonl_for_append(
260
+ storage.project_directory, layout.transcript_path,
261
+ )
262
+ messages = _messages(records)
263
+ writer = WritableSession(self, storage, meta, messages)
264
+ del records, messages
265
+ try:
266
+ yield writer
267
+ finally:
268
+ with writer._mutex:
269
+ writer._closed = True
270
+
271
+ def create_session(
272
+ self, project: ProjectIdentity, *, session_id: str | None = None,
273
+ title: str = DEFAULT_SESSION_TITLE,
274
+ ) -> SessionRecord:
275
+ with self.open_session(project, session_id, create=True, title=title) as session:
276
+ return session.record
277
+
278
+ def load_conversation(self, project: ProjectIdentity, session_id: str) -> Conversation:
279
+ with _storage_errors():
280
+ storage = self.project_storage(project)
281
+ self._metadata(storage, session_id)
282
+ records = read_jsonl_records(
283
+ storage.project_directory, storage.session(session_id).transcript_path,
284
+ trailing_record="ignore",
285
+ )
286
+ return Conversation.from_messages(_messages(records))
287
+
288
+ def append_message(self, project: ProjectIdentity, session_id: str, message: Message) -> None:
289
+ self.append_messages(project, session_id, [message])
290
+
291
+ def append_messages(
292
+ self, project: ProjectIdentity, session_id: str, messages: Iterable[Message],
293
+ ) -> None:
294
+ with self.open_session(project, session_id) as session:
295
+ session.append_messages(messages)
296
+
297
+ def rename_session(self, project: ProjectIdentity, session_id: str, title: str) -> SessionRecord:
298
+ with self.open_session(project, session_id) as session:
299
+ return session.rename(title)
300
+
301
+ def load_compact_state(self, project: ProjectIdentity, session_id: str) -> CompactState:
302
+ with _storage_errors():
303
+ storage = self.project_storage(project)
304
+ self._metadata(storage, session_id)
305
+ message_count = len(self.load_conversation(project, session_id).get_messages())
306
+ return _load_compact_snapshot(storage, session_id, message_count)
307
+
308
+ def save_compact_state(self, project: ProjectIdentity, session_id: str, state: CompactState) -> None:
309
+ with self.open_session(project, session_id) as session:
310
+ session.save_compact_state(state)
311
+
312
+ def delete_session(self, project: ProjectIdentity, session_id: str) -> bool:
313
+ with _storage_errors():
314
+ storage = self.project_storage(project)
315
+ layout = storage.session(session_id)
316
+ with SessionLifecycleLock(layout.lock_path, timeout_seconds=0).acquire():
317
+ if not layout.root.exists():
318
+ return False
319
+ _validate_deletion_tree(layout.root)
320
+ # Revoke logical existence before any partial physical cleanup.
321
+ layout.meta_path.unlink(missing_ok=True)
322
+ shutil.rmtree(layout.root)
323
+ return True
324
+
325
+
326
+ class WritableSession:
327
+ """Scoped write capability; all methods reject use after the context exits."""
328
+
329
+ def __init__(
330
+ self, store: SessionStore, storage: ProjectStorage,
331
+ metadata: _Metadata, messages: list[Message],
332
+ ) -> None:
333
+ self.store = store
334
+ self.storage = storage
335
+ self.layout = storage.session(metadata.session_id)
336
+ self._meta = metadata
337
+ self._messages = list(messages)
338
+ self._closed = False
339
+ self._mutex = RLock()
340
+
341
+ @property
342
+ def record(self) -> SessionRecord:
343
+ return self.store._record(self.storage.identity, self._meta)
344
+
345
+ def _check_open(self) -> None:
346
+ if self._closed:
347
+ raise SessionStoreError("Session writer is closed.")
348
+ self.storage.session(self.record.id)
349
+
350
+ @property
351
+ def _message_count(self) -> int:
352
+ return len(self._messages)
353
+
354
+ def load_history(self) -> Conversation:
355
+ with self._mutex, _storage_errors():
356
+ self._check_open()
357
+ return Conversation.from_messages(self._messages)
358
+
359
+ def _save_metadata(self) -> None:
360
+ self._meta.updated_at = self.store._now()
361
+ write_json_snapshot(
362
+ self.storage.project_directory, self.layout.meta_path, self._meta.model_dump(mode="json"),
363
+ )
364
+
365
+ def append_messages(self, messages: Iterable[Message]) -> None:
366
+ with self._mutex, _storage_errors():
367
+ self._check_open()
368
+ prepared = [
369
+ _transcript(message, self._message_count + i + 1)
370
+ for i, message in enumerate(messages)
371
+ ]
372
+ for record in prepared:
373
+ message = _TranscriptRecord.model_validate(record).message()
374
+ append_jsonl_record(self.storage.project_directory, self.layout.transcript_path, record)
375
+ self._messages.append(message)
376
+ if prepared:
377
+ self._save_metadata()
378
+
379
+ def rename(self, title: str) -> SessionRecord:
380
+ with self._mutex, _storage_errors():
381
+ self._check_open()
382
+ self._meta.title = _title(title)
383
+ self._save_metadata()
384
+ return self.record
385
+
386
+ def finish(self, status: Literal["closed", "interrupted"]) -> SessionRecord:
387
+ with self._mutex, _storage_errors():
388
+ self._check_open()
389
+ self._meta.last_terminal_state = status
390
+ self._save_metadata()
391
+ return self.record
392
+
393
+ def save_compact_state(self, state: CompactState) -> None:
394
+ with self._mutex, _storage_errors():
395
+ self._check_open()
396
+ _validate_compact(state, self._message_count)
397
+ write_json_snapshot(
398
+ self.storage.project_directory, self.layout.compact_path, state.model_dump(mode="json"),
399
+ )
400
+
401
+ def load_or_reset_compact_state(self) -> CompactStateLoadResult:
402
+ with self._mutex, _storage_errors():
403
+ self._check_open()
404
+ storage = self.store.project_storage(self.storage.identity)
405
+ self.store._metadata(storage, self.record.id)
406
+ try:
407
+ state = _load_compact_snapshot(storage, self.record.id, self._message_count)
408
+ return CompactStateLoadResult(state)
409
+ except CompactStateDataError:
410
+ state = CompactState(
411
+ consecutive_failure_count=1,
412
+ retry_after_message_count=self._message_count + DEFAULT_COMPACT_FAILURE_COOLDOWN_MESSAGES,
413
+ last_failure_reason="stored_compact_state_invalid",
414
+ )
415
+ self.save_compact_state(state)
416
+ return CompactStateLoadResult(state, True)
417
+
418
+ def append_subagent_event(self, run_id: str, event: dict[str, object]) -> None:
419
+ with self._mutex, _storage_errors():
420
+ self._check_open()
421
+ if event.get("type") not in {"state", "snapshot", "tool_audit", "result"}:
422
+ raise SessionDataError("Unsupported SubAgent event.")
423
+ append_jsonl_record(
424
+ self.storage.project_directory, self.layout.subagent_log_path(run_id),
425
+ {**event, "version": 1},
426
+ )
427
+
428
+
429
+ def _title(value: str) -> str:
430
+ if not isinstance(value, str) or not value.strip() or len(value.strip()) > 200:
431
+ raise SessionDataError("Invalid session title.")
432
+ return value.strip()
433
+
434
+
435
+ def _transcript(message: Message, sequence: int) -> dict[str, object]:
436
+ record = _TranscriptRecord(
437
+ sequence=sequence, role=message.role, content=message.content,
438
+ tool_calls=[_ToolCall(id=c.id, name=c.name, arguments=c.arguments) for c in message.tool_calls],
439
+ tool_call_id=message.tool_call_id, reasoning_content=message.reasoning_content,
440
+ reasoning_state=message.reasoning_state,
441
+ )
442
+ payload = record.model_dump(mode="json")
443
+ try:
444
+ json.dumps(payload, allow_nan=False)
445
+ except ValueError as error:
446
+ raise SessionDataError("Tool arguments must be finite JSON.") from error
447
+ return payload
448
+
449
+
450
+ def _messages(records: list[dict[str, object]]) -> list[Message]:
451
+ messages = []
452
+ for sequence, raw in enumerate(records, 1):
453
+ try:
454
+ json.dumps(raw, allow_nan=False)
455
+ except ValueError as error:
456
+ raise SessionDataError("Transcript contains nonfinite JSON.") from error
457
+ record = _TranscriptRecord.model_validate(raw)
458
+ if record.sequence != sequence:
459
+ raise SessionDataError("Transcript sequence is not contiguous.")
460
+ messages.append(record.message())
461
+ return messages
462
+
463
+
464
+ def _load_compact_snapshot(
465
+ storage: ProjectStorage, session_id: str, message_count: int,
466
+ ) -> CompactState:
467
+ path = storage.session(session_id).compact_path
468
+ try:
469
+ path.lstat()
470
+ except FileNotFoundError:
471
+ return CompactState()
472
+ try:
473
+ payload = read_json_snapshot(storage.project_directory, path)
474
+ except JsonSnapshotError as error:
475
+ if isinstance(error.__cause__, OSError):
476
+ raise # I/O failure is not evidence of corrupt Compact data.
477
+ raise CompactStateDataError("Invalid Compact JSON snapshot.") from error
478
+ try:
479
+ state = CompactState.model_validate(payload)
480
+ except ValidationError as error:
481
+ raise CompactStateDataError("Invalid Compact schema.") from error
482
+ _validate_compact(state, message_count)
483
+ return state
484
+
485
+
486
+ def _validate_compact(state: CompactState, message_count: int) -> None:
487
+ if len(state.model_dump_json()) > MAX_COMPACT_STATE_JSON_CHARS:
488
+ raise CompactStateDataError("Session Compact state exceeds size limit.")
489
+ if state.boundary and state.boundary.covered_message_count > message_count:
490
+ raise CompactStateDataError("Compact boundary exceeds persisted history.")
491
+
492
+
493
+ def _validate_deletion_tree(path: Path) -> None:
494
+ import stat
495
+
496
+ info = path.lstat()
497
+ if path.is_symlink() or getattr(info, "st_file_attributes", 0) & getattr(
498
+ stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0,
499
+ ):
500
+ raise SessionDataError("Session deletion refuses links/reparse points.")
501
+ if path.is_dir():
502
+ for child in path.iterdir():
503
+ _validate_deletion_tree(child)
@@ -0,0 +1 @@
1
+ """Presentation adapters for interactive frontends."""
@@ -0,0 +1,14 @@
1
+ """CLI presentation, confirmation, and SubAgent observation adapters."""
2
+
3
+ from mycode.presentation.cli.confirmer import TerminalConfirmer
4
+ from mycode.presentation.cli.mcp_trust import TerminalMCPTrustConfirmer
5
+ from mycode.presentation.cli.presenter import CliDisplayMode, CliPresenter
6
+ from mycode.presentation.cli.subagent_observer import CliSubAgentObserver
7
+
8
+ __all__ = [
9
+ "CliDisplayMode",
10
+ "CliPresenter",
11
+ "CliSubAgentObserver",
12
+ "TerminalConfirmer",
13
+ "TerminalMCPTrustConfirmer",
14
+ ]
@@ -0,0 +1,116 @@
1
+ from collections.abc import Callable
2
+
3
+ from mycode.permissions import ApprovalScope, ConfirmationRequest, ConfirmationResult
4
+
5
+
6
+ class TerminalConfirmer:
7
+ def __init__(
8
+ self,
9
+ input_func: Callable[[str], str] = input,
10
+ output_func: Callable[[str], None] = print,
11
+ ) -> None:
12
+ self.input_func = input_func
13
+ self.output_func = output_func
14
+
15
+ def confirm(self, request: ConfirmationRequest) -> ConfirmationResult:
16
+ permission_request = request.permission_request
17
+ permission_decision = request.permission_decision
18
+
19
+ self.output_func(
20
+ f"permission> {permission_request.tool_name} 需要确认"
21
+ )
22
+ if permission_request.target is not None:
23
+ self.output_func(f"target> {permission_request.target}")
24
+ self._output_metadata("resolved_path", request)
25
+ self._output_metadata("workspace_root", request)
26
+ self._output_metadata("path_scope", request)
27
+ self._output_metadata("pattern_scope", request)
28
+ self._output_metadata("command_display", request)
29
+ self._output_metadata("resolved_cwd", request)
30
+ self._output_metadata("cwd_scope", request)
31
+ self._output_metadata("command_risk_category", request)
32
+ self._output_metadata("command_risk", request)
33
+ self._output_metadata("command_risk_reason", request)
34
+ self._output_metadata("memory_scope", request)
35
+ self._output_metadata("memory_kind", request)
36
+ self._output_metadata("memory_key", request)
37
+ self._output_metadata("memory_content", request)
38
+ self._output_metadata("memory_path", request)
39
+ self._output_metadata("skill_name", request)
40
+ self._output_metadata("skill_source", request)
41
+ self._output_metadata("script", request)
42
+ self._output_metadata("arguments", request)
43
+ self._output_metadata("cwd", request)
44
+ self.output_func(f"reason> {permission_decision.reason}")
45
+ if request.prompt:
46
+ self.output_func(
47
+ f"message> {_localize_confirmation_message(request.prompt)}"
48
+ )
49
+
50
+ try:
51
+ answer = self.input_func(
52
+ "是否批准?[y/yes 本次 | t/task 当前任务 | s/session 当前会话 | N 拒绝] "
53
+ ).strip().lower()
54
+ except EOFError:
55
+ return ConfirmationResult.rejected(
56
+ message="Permission confirmation unavailable.",
57
+ metadata={"input": "eof"},
58
+ )
59
+
60
+ scopes: dict[str, ApprovalScope] = {
61
+ "y": "once", "yes": "once", "t": "task", "task": "task",
62
+ "s": "session", "session": "session",
63
+ }
64
+ if answer in scopes:
65
+ return ConfirmationResult.approved(
66
+ scope=scopes[answer],
67
+ message="Permission confirmation approved.",
68
+ metadata={"input": answer},
69
+ )
70
+
71
+ return ConfirmationResult.rejected(
72
+ message="Permission confirmation rejected.",
73
+ metadata={"input": answer},
74
+ )
75
+
76
+ def _output_metadata(
77
+ self,
78
+ key: str,
79
+ request: ConfirmationRequest,
80
+ ) -> None:
81
+ value = _metadata_value(request, key)
82
+ if value is not None:
83
+ if key == "memory_content":
84
+ self.output_func(f"{key}> {value!r}")
85
+ return
86
+ self.output_func(f"{key}> {value}")
87
+
88
+
89
+ def _metadata_value(
90
+ request: ConfirmationRequest,
91
+ key: str,
92
+ ) -> object | None:
93
+ if key in request.metadata:
94
+ return request.metadata[key]
95
+
96
+ return request.permission_decision.metadata.get(key)
97
+
98
+
99
+ def _localize_confirmation_message(message: str) -> str:
100
+ exact_translations = {
101
+ "Sensitive path requires confirmation.": "敏感路径需要确认。",
102
+ }
103
+ if message in exact_translations:
104
+ return exact_translations[message]
105
+ translations = {
106
+ "Path outside workspace requires confirmation: ": "工作区外路径需要确认:",
107
+ "Sensitive path requires confirmation: ": "敏感路径需要确认:",
108
+ "Ignored path requires confirmation: ": "忽略路径需要确认:",
109
+ "Sensitive path pattern requires confirmation: ": "敏感路径模式需要确认:",
110
+ "Write operation requires confirmation: ": "写操作需要确认:",
111
+ "Command operation requires confirmation: ": "命令操作需要确认:",
112
+ }
113
+ for prefix, translated in translations.items():
114
+ if message.startswith(prefix):
115
+ return translated + message[len(prefix) :]
116
+ return message