stcode 0.0.1a0__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 (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,88 @@
1
+ """Daemon workspace operations for explicit client requests."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ FILE_LIMIT = 2000
12
+
13
+
14
+ def list_files(root: Path, limit: int = FILE_LIMIT) -> list[str]:
15
+ """Workspace paths for `@`, relative to `root`, newest convention first: git."""
16
+ try:
17
+ finished = subprocess.run(
18
+ ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
19
+ cwd=root,
20
+ capture_output=True,
21
+ timeout=5,
22
+ check=False,
23
+ )
24
+ if finished.returncode == 0 and finished.stdout:
25
+ # -z disables Git's C-style quoting and preserves whitespace in names.
26
+ paths = finished.stdout.rstrip(b"\0").split(b"\0")
27
+ return sorted({os.fsdecode(path) for path in paths})[:limit]
28
+ except (OSError, subprocess.SubprocessError):
29
+ pass
30
+ return _walk(root, limit)
31
+
32
+
33
+ def _walk(root: Path, limit: int) -> list[str]:
34
+ """Every file under `root`, dot-directories skipped and bounded.
35
+
36
+ Bounded because this runs on a directory nobody vetted: a home directory, or a
37
+ workspace with a 40,000-file build output in it, must not turn typing `@` into a
38
+ minute of walking.
39
+ """
40
+ found: list[str] = []
41
+ for current, directories, filenames in os.walk(root):
42
+ directories[:] = [name for name in directories if not name.startswith(".")]
43
+ for filename in filenames:
44
+ if filename.startswith("."):
45
+ continue
46
+ found.append(str(Path(current, filename).relative_to(root)))
47
+ if len(found) >= limit:
48
+ return sorted(found)
49
+ return sorted(found)
50
+
51
+
52
+ async def run_shell(command: str, cwd: Path, timeout: float) -> dict[str, Any]:
53
+ """Run a user command in the session workspace; reap its group on timeout/close."""
54
+ timeout = max(1.0, min(120.0, timeout))
55
+ process = await asyncio.create_subprocess_shell(
56
+ command, cwd=cwd, stdin=asyncio.subprocess.DEVNULL,
57
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
58
+ start_new_session=True,
59
+ )
60
+ # Drain continuously but bound the retained output, including single huge lines.
61
+ output = bytearray()
62
+ truncated = False
63
+
64
+ async def drain() -> None:
65
+ nonlocal truncated
66
+ assert process.stdout is not None
67
+ while chunk := await process.stdout.read(65536):
68
+ remaining = max(0, 1024 * 1024 - len(output))
69
+ output.extend(chunk[:remaining])
70
+ truncated |= len(chunk) > remaining
71
+ await process.wait()
72
+
73
+ pump = asyncio.create_task(drain())
74
+ timed_out = False
75
+ try:
76
+ await asyncio.wait_for(asyncio.shield(pump), timeout)
77
+ except asyncio.TimeoutError:
78
+ timed_out = True
79
+ finally:
80
+ if not pump.done():
81
+ with contextlib.suppress(ProcessLookupError):
82
+ os.killpg(process.pid, signal.SIGKILL)
83
+ await pump
84
+ text = output.decode("utf-8", errors="replace").rstrip()
85
+ if truncated:
86
+ text += "\n… output truncated at 1 MiB"
87
+ return {"output": text, "exit_code": 124 if timed_out else process.returncode,
88
+ "timed_out": timeout if timed_out else None}
@@ -0,0 +1,81 @@
1
+ """
2
+ Harness — the tools, prompts, and skills an agent works with.
3
+
4
+ harness = await Harness.create(cwd=project_root, approval_mode="auto-edit")
5
+ system = harness.system_prompt()
6
+ tools = harness.tool_definitions()
7
+ result = await harness.invoke("read", {"path": "src/main.py"})
8
+
9
+ * `tools/base.py` — `@tool`, and `Runtime[T]`, the parameter the model cannot see.
10
+ * `tools/schema.py` — signature + docstring → JSON Schema.
11
+ * `approvals.py` — the permission vocabulary and the mode policy.
12
+ * `context.py` — `HarnessContext`, the workspace state tools share.
13
+ * `tools/registry.py` — which tools exist and which an agent may see.
14
+ * `skills/` — `SKILL.md` discovery, loaded on demand.
15
+ * `prompts/` — the plan and execute prompts, and the sub-agent briefing.
16
+ * `mcp.py` — external tool servers, adapted to the same `Tool` interface.
17
+ * `harness.py` — the facade that composes all of it.
18
+ """
19
+
20
+ from stcode.core.harness.approvals import (
21
+ APPROVAL_MODES,
22
+ DEFAULT_APPROVAL_MODE,
23
+ ApprovalMode,
24
+ ToolPermission,
25
+ is_forbidden,
26
+ requires_approval,
27
+ )
28
+ from stcode.core.harness.context import HarnessContext, TodoItem
29
+ from stcode.core.harness.harness import Harness, read_project_instructions
30
+ from stcode.core.harness.mcp import MCPManager, MCPServerConfig, load_mcp_config
31
+ from stcode.core.harness.prompts import PromptMode, build_system_prompt, mode_for
32
+ from stcode.core.harness.skills import Skill, SkillRegistry
33
+ from stcode.core.harness.tools import (
34
+ BUILTIN_TOOLS,
35
+ MAIN_TOOLS,
36
+ READ_ONLY_TOOLS,
37
+ WORKER_TOOLS,
38
+ ApprovalRequest,
39
+ Question,
40
+ Runtime,
41
+ Tool,
42
+ ToolError,
43
+ ToolRegistry,
44
+ ToolResult,
45
+ to_tool_definition,
46
+ tool,
47
+ )
48
+
49
+ __all__ = [
50
+ "APPROVAL_MODES",
51
+ "BUILTIN_TOOLS",
52
+ "DEFAULT_APPROVAL_MODE",
53
+ "MAIN_TOOLS",
54
+ "READ_ONLY_TOOLS",
55
+ "WORKER_TOOLS",
56
+ "ApprovalMode",
57
+ "ApprovalRequest",
58
+ "Harness",
59
+ "HarnessContext",
60
+ "MCPManager",
61
+ "MCPServerConfig",
62
+ "PromptMode",
63
+ "Question",
64
+ "Runtime",
65
+ "Skill",
66
+ "SkillRegistry",
67
+ "TodoItem",
68
+ "Tool",
69
+ "ToolError",
70
+ "ToolPermission",
71
+ "ToolRegistry",
72
+ "ToolResult",
73
+ "build_system_prompt",
74
+ "is_forbidden",
75
+ "load_mcp_config",
76
+ "mode_for",
77
+ "read_project_instructions",
78
+ "requires_approval",
79
+ "to_tool_definition",
80
+ "tool",
81
+ ]
@@ -0,0 +1,85 @@
1
+ """
2
+ Approval modes — how much the agent may do without asking.
3
+
4
+ The vocabulary is `core/configs.py`'s; this module owns what each mode permits.
5
+
6
+ `ToolPermission` and the policy below are the enforcement half: a tool declares its
7
+ class of side effect once, at definition, and this module — not the tool — decides
8
+ whether that class runs unattended under the current mode.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from stcode.core.common.compat import StrEnum
14
+ from stcode.core.configs import APPROVAL_MODES, ApprovalMode # noqa: F401 — re-exported
15
+
16
+ DEFAULT_APPROVAL_MODE: ApprovalMode = "suggest"
17
+
18
+
19
+ class ToolPermission(StrEnum):
20
+ """What class of side effect a tool has — the axis approval modes gate on.
21
+
22
+ Declared once at definition; the policy below decides what it means under the
23
+ current mode. Tools never test the mode themselves — one that knows about
24
+ `full-auto` will eventually disagree with the next one about what it means.
25
+ """
26
+
27
+ READ = "read"
28
+ """Observes the workspace and nothing else. Safe in every mode, `plan` included."""
29
+
30
+ WRITE = "write"
31
+ """Mutates the filesystem. The thing `plan` exists to forbid."""
32
+
33
+ EXECUTE = "execute"
34
+ """Runs an arbitrary command or arbitrary Python. Strictly wider than WRITE — a
35
+ shell can write files, and it can also delete the repository."""
36
+
37
+ NETWORK = "network"
38
+ """Leaves the machine. Gated separately from WRITE because the risk is disclosure,
39
+ not corruption: the workspace survives, its contents may not stay private."""
40
+
41
+ INTERACTIVE = "interactive"
42
+ """Asks the human something. Always allowed — a mode that blocks the agent from
43
+ asking permission-shaped questions has the gate backwards."""
44
+
45
+
46
+ # Which permissions run unattended, which ask, and which are refused outright.
47
+ #
48
+ # The ordering is about *workspace mutation*: each mode adds one class to the previous —
49
+ # nothing, then writes, then arbitrary execution. NETWORK is orthogonal (the risk is
50
+ # disclosure, not corruption), so it is gated on its own rather than wedged in.
51
+ _UNATTENDED: dict[ApprovalMode, frozenset[ToolPermission]] = {
52
+ "plan": frozenset({ToolPermission.READ, ToolPermission.INTERACTIVE}),
53
+ "suggest": frozenset({ToolPermission.READ, ToolPermission.INTERACTIVE}),
54
+ "auto-edit": frozenset(
55
+ {
56
+ ToolPermission.READ,
57
+ ToolPermission.INTERACTIVE,
58
+ ToolPermission.WRITE,
59
+ ToolPermission.NETWORK,
60
+ }
61
+ ),
62
+ "full-auto": frozenset(ToolPermission),
63
+ }
64
+
65
+ _FORBIDDEN: dict[ApprovalMode, frozenset[ToolPermission]] = {
66
+ # `plan` is a research mode: a mid-plan "may I write this file?" prompt would
67
+ # defeat it rather than enforce it. Network is not refused — it only mutates what
68
+ # the agent knows — but it asks, because egress is a disclosure to authorise.
69
+ "plan": frozenset({ToolPermission.WRITE, ToolPermission.EXECUTE}),
70
+ }
71
+
72
+
73
+ def requires_approval(mode: ApprovalMode, permission: ToolPermission) -> bool:
74
+ """Whether a tool of this permission class must ask the human before running."""
75
+ return permission not in _UNATTENDED.get(mode, _UNATTENDED[DEFAULT_APPROVAL_MODE])
76
+
77
+
78
+ def is_forbidden(mode: ApprovalMode, permission: ToolPermission) -> bool:
79
+ """Whether this mode refuses the tool outright, with no prompt to offer.
80
+
81
+ Distinct from `requires_approval`: "ask first" is a pause a human can resolve, while
82
+ this is a capability the mode does not have. The agent needs to know which it hit —
83
+ retrying a forbidden tool is never going to work.
84
+ """
85
+ return permission in _FORBIDDEN.get(mode, frozenset())
@@ -0,0 +1,260 @@
1
+ """
2
+ Harness context — the workspace state built-in tools share, and the `T` in `Runtime[T]`.
3
+
4
+ `Runtime` carries one call's plumbing (who, when, may-I). This carries what the
5
+ *session* knows: where it is rooted, what it may write to, what it has read, what it
6
+ plans next. The split is what lets another agent define its own context and reuse the
7
+ same `@tool` machinery.
8
+
9
+ Two invariants live here rather than in the tools that depend on them, because a rule
10
+ enforced in four places will soon be enforced in three:
11
+
12
+ **Path scope.** Two sub-agents must never write the same file. Scope is assigned at
13
+ spawn and checked in `ensure_writable`.
14
+
15
+ **Read before overwrite.** `write` to an existing file requires this session to have
16
+ read it. Clobbering a file the agent never looked at is its most expensive mistake, and
17
+ it cannot be detected afterwards — the old contents are simply gone.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import os
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any, Iterable, Literal
27
+
28
+ from pydantic import BaseModel, Field
29
+
30
+ from stcode.core.configs import ToolConfig
31
+ from stcode.core.harness.errors import ToolError
32
+
33
+ if TYPE_CHECKING: # pragma: no cover - import cycle at runtime, fine for type checkers
34
+ from stcode.core.harness.skills import SkillRegistry
35
+ from stcode.core.harness.tools.shell import BackgroundShell
36
+ from stcode.core.repl import PyREPL
37
+
38
+ TodoStatus = Literal["pending", "in_progress", "completed"]
39
+
40
+ DEFAULT_IGNORED_DIRS = frozenset(
41
+ {
42
+ ".git",
43
+ ".hg",
44
+ ".svn",
45
+ ".venv",
46
+ "venv",
47
+ "node_modules",
48
+ "__pycache__",
49
+ ".mypy_cache",
50
+ ".pytest_cache",
51
+ ".ruff_cache",
52
+ "dist",
53
+ "build",
54
+ ".next",
55
+ "target",
56
+ }
57
+ )
58
+ """Skipped when no `.gitignore` is available. Not a security boundary — a deliberate
59
+ `read(".venv/...")` still works. It exists so `ls` and `glob` answer the question the
60
+ agent meant to ask."""
61
+
62
+
63
+ # These field descriptions end up in a tool's JSON Schema, so they are written for the
64
+ # model: short, imperative, no rationale. Two phrasings because the UI shows the
65
+ # participle while an item runs — deriving one from the other in English does not
66
+ # survive contact with real task names.
67
+ class TodoItem(BaseModel):
68
+ """A single task in the plan."""
69
+
70
+ content: str = Field(description="The task, in the imperative: 'Add the retry test'.")
71
+ status: TodoStatus = Field(default="pending", description="Where this task stands.")
72
+ active_form: str = Field(
73
+ default="",
74
+ description="The same task in the present participle, shown while it runs: "
75
+ "'Adding the retry test'.",
76
+ )
77
+
78
+ def render(self) -> str:
79
+ mark = {"pending": "[ ]", "in_progress": "[~]", "completed": "[x]"}[self.status]
80
+ return f"{mark} {self.content}"
81
+
82
+
83
+ @dataclass
84
+ class HarnessContext:
85
+ """What the built-in tools know about this session's workspace."""
86
+
87
+ cwd: Path = field(default_factory=Path.cwd)
88
+ """Root for every relative path a tool is given, and the default search root."""
89
+
90
+ scope: tuple[Path, ...] = ()
91
+ """Paths this agent may write to. Empty means all of `cwd` — the main agent's
92
+ normal state. A sub-agent gets a narrower tuple at spawn."""
93
+
94
+ read_files: dict[Path, float] = field(default_factory=dict)
95
+ """Absolute path -> mtime at last read. Feeds the read-before-overwrite check, and
96
+ lets `edit` notice a file that changed underneath it."""
97
+
98
+ todos: list[TodoItem] = field(default_factory=list)
99
+ session_ctx: dict[str, Any] = field(default_factory=dict)
100
+ """Knowledge shared down into sibling sub-agents — the in-process twin of the
101
+ REPL's `session_ctx`."""
102
+
103
+ shells: dict[str, "BackgroundShell"] = field(default_factory=dict)
104
+ """Background processes started by `bash(..., background=True)`, by shell id."""
105
+
106
+ skills: "SkillRegistry | None" = None
107
+
108
+ repl: "PyREPL | None" = None
109
+ """The session's persistent Python REPL. Attached by `Harness.create` and lazy —
110
+ the subprocess waits for the first cell. Read by the `repl` tool and by
111
+ `Harness.invoke`, which pushes elided payloads into its `tool_out`.
112
+
113
+ None for a sub-agent and for `load_repl=False`. When None, the elision hint drops
114
+ back to "ask for a narrower range" — `tool_out` is then unreachable."""
115
+
116
+ git: str = ""
117
+ """Branch, status and recent commits. Sampled once at `Harness.create` and never
118
+ refreshed: the system prompt is the cached prefix, and re-sampling per turn would
119
+ invalidate it every turn. A turn needing current state runs `git status`."""
120
+
121
+ env: dict[str, str] = field(default_factory=dict)
122
+ """Extra environment for spawned processes, layered over `os.environ`."""
123
+
124
+ tool_config: ToolConfig = field(default_factory=ToolConfig)
125
+ """The complete `[tools]` configuration, including service credentials."""
126
+
127
+ # ---- paths ----
128
+
129
+ def resolve(self, path: str | Path) -> Path:
130
+ """Absolute, `~`-expanded, and rooted at `cwd` when relative.
131
+
132
+ Not `Path.resolve()`: it follows symlinks, so a repo under a symlinked home
133
+ would fail its own scope check for paths the user legitimately typed. Only `..`
134
+ is normalised — the part that matters for escaping a scope.
135
+ """
136
+ candidate = Path(os.path.expanduser(str(path)))
137
+ if not candidate.is_absolute():
138
+ candidate = self.cwd / candidate
139
+ return Path(os.path.normpath(candidate))
140
+
141
+ def in_scope(self, path: str | Path) -> bool:
142
+ target = self.resolve(path)
143
+ roots: Iterable[Path] = self.scope or (self.cwd,)
144
+ return any(target == root or root in target.parents for root in roots)
145
+
146
+ def ensure_writable(self, path: str | Path) -> Path:
147
+ """Resolve `path` and refuse it if this agent has no business writing there."""
148
+ target = self.resolve(path)
149
+ if self.in_scope(target):
150
+ return target
151
+ allowed = ", ".join(str(p) for p in (self.scope or (self.cwd,)))
152
+ raise ToolError(
153
+ f"{target} is outside this agent's write scope ({allowed}). Another agent "
154
+ "owns that path — report what needs changing there instead of editing it."
155
+ )
156
+
157
+ def note_read(self, path: Path) -> None:
158
+ try:
159
+ self.read_files[path] = path.stat().st_mtime
160
+ except OSError:
161
+ self.read_files[path] = 0.0
162
+
163
+ def has_read(self, path: Path) -> bool:
164
+ return path in self.read_files
165
+
166
+ def changed_since_read(self, path: Path) -> bool:
167
+ """Whether `path` changed since this session read it.
168
+
169
+ Guards against editing from a stale mental model — someone else's save, or a
170
+ sibling agent that should not have been in there at all.
171
+ """
172
+ seen = self.read_files.get(path)
173
+ if seen is None:
174
+ return False
175
+ try:
176
+ return path.stat().st_mtime > seen
177
+ except OSError:
178
+ return False
179
+
180
+ # ---- todos ----
181
+
182
+ def render_todos(self) -> str:
183
+ if not self.todos:
184
+ return "(no todos)"
185
+ return "\n".join(item.render() for item in self.todos)
186
+
187
+ # ---- process environment ----
188
+
189
+ def process_env(self) -> dict[str, str]:
190
+ return {**os.environ, **self.env}
191
+
192
+
193
+ class TodoList(BaseModel):
194
+ """Wrapper so `todo_write` takes one well-named argument rather than a bare list."""
195
+
196
+ items: list[TodoItem] = Field(default_factory=list)
197
+
198
+
199
+ # ---- repository state ------------------------------------------------------------
200
+
201
+ GIT_TIMEOUT = 5.0
202
+ """Past this, the repo is large enough or the filesystem slow enough that blocking
203
+ startup is the wrong trade. The prompt loses a section; `git status` still works."""
204
+
205
+ _GIT_QUERIES = (
206
+ ("Branch", ("rev-parse", "--abbrev-ref", "HEAD")),
207
+ ("Status", ("status", "--short", "--branch")),
208
+ ("Recent commits", ("log", "-5", "--oneline", "--no-decorate")),
209
+ )
210
+
211
+
212
+ async def git_context(cwd: Path) -> str:
213
+ """Branch, working-tree status and the last five commits, as a prompt section.
214
+
215
+ Sampled once and never refreshed: it is the highest quality-per-line addition to
216
+ the prompt, and re-running it per turn would move the prompt and undo the caching.
217
+
218
+ Returns "" outside a repository or without `git`. A missing section is correct
219
+ here, not an error — plenty of work happens outside a checkout.
220
+ """
221
+ async def run(arguments: tuple[str, ...]) -> str:
222
+ try:
223
+ process = await asyncio.create_subprocess_exec(
224
+ "git", "-C", str(cwd), *arguments,
225
+ stdout=asyncio.subprocess.PIPE,
226
+ stderr=asyncio.subprocess.DEVNULL,
227
+ stdin=asyncio.subprocess.DEVNULL,
228
+ )
229
+ stdout, _ = await asyncio.wait_for(process.communicate(), GIT_TIMEOUT)
230
+ except (OSError, asyncio.TimeoutError):
231
+ return ""
232
+ if process.returncode != 0:
233
+ return ""
234
+ return stdout.decode("utf-8", errors="replace").strip()
235
+
236
+ if not await run(("rev-parse", "--is-inside-work-tree")):
237
+ return ""
238
+
239
+ sections = await asyncio.gather(*(run(arguments) for _, arguments in _GIT_QUERIES))
240
+ lines = ["Repository:"]
241
+ for (label, _), output in zip(_GIT_QUERIES, sections):
242
+ if not output:
243
+ continue
244
+ if "\n" in output:
245
+ lines.append(f"{label}:")
246
+ lines += [f" {line}" for line in output.splitlines()[:12]]
247
+ else:
248
+ lines.append(f"{label}: {output}")
249
+ return "\n".join(lines) if len(lines) > 1 else ""
250
+
251
+
252
+ __all__ = [
253
+ "DEFAULT_IGNORED_DIRS",
254
+ "GIT_TIMEOUT",
255
+ "HarnessContext",
256
+ "TodoItem",
257
+ "TodoList",
258
+ "TodoStatus",
259
+ "git_context",
260
+ ]
@@ -0,0 +1,40 @@
1
+ """
2
+ Tool failures — the vocabulary for something going wrong.
3
+
4
+ A leaf module: `context.py` raises these and `tools/base.py` catches them, so putting
5
+ them in either would make a cycle. The agent loop reacts differently to each:
6
+
7
+ * `ToolError` — the tool worked, and the answer is "no, because…".
8
+ * `ToolDenied` — a human said no. Could go the other way next time.
9
+ * `ToolForbidden` — the mode lacks this capability. Retrying cannot help.
10
+ * `ToolCancelled` / `ToolTimeout` — did not finish. An interrupt means the user wants
11
+ something else; a timeout means narrow the request.
12
+
13
+ A bug *inside* a tool is none of these: logged with its traceback, summarised for the
14
+ model. A stack frame is not advice.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+
20
+ class ToolError(Exception):
21
+ """A tool failed in a way the model should read and react to."""
22
+
23
+
24
+ class ToolDenied(ToolError):
25
+ """The human was asked to approve this call and said no."""
26
+
27
+
28
+ class ToolForbidden(ToolError):
29
+ """The current approval mode does not have this capability at all."""
30
+
31
+
32
+ class ToolCancelled(ToolError):
33
+ """The session was interrupted while this tool was running."""
34
+
35
+
36
+ class ToolTimeout(ToolError):
37
+ """The tool exceeded its time budget."""
38
+
39
+
40
+ __all__ = ["ToolCancelled", "ToolDenied", "ToolError", "ToolForbidden", "ToolTimeout"]