handler-ai 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 (234) hide show
  1. handler_ai-0.1.0.dist-info/METADATA +60 -0
  2. handler_ai-0.1.0.dist-info/RECORD +234 -0
  3. handler_ai-0.1.0.dist-info/WHEEL +4 -0
  4. handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
  5. handler_cli/__init__.py +1 -0
  6. handler_cli/account_commands.py +314 -0
  7. handler_cli/addon_commands.py +128 -0
  8. handler_cli/agent_commands.py +139 -0
  9. handler_cli/app_commands.py +175 -0
  10. handler_cli/app_window.py +85 -0
  11. handler_cli/automation_commands.py +309 -0
  12. handler_cli/bridge.py +42 -0
  13. handler_cli/cli.py +326 -0
  14. handler_cli/client.py +81 -0
  15. handler_cli/daemon_commands.py +108 -0
  16. handler_cli/image_commands.py +102 -0
  17. handler_cli/inbox_commands.py +33 -0
  18. handler_cli/output.py +12 -0
  19. handler_cli/project_commands.py +63 -0
  20. handler_cli/provider_commands.py +113 -0
  21. handler_cli/secret_commands.py +64 -0
  22. handler_cli/session_bridge.py +93 -0
  23. handler_cli/session_commands.py +485 -0
  24. handler_cli/setup.py +120 -0
  25. handler_cli/terminal.py +280 -0
  26. handler_cli/terminal_client.py +37 -0
  27. handler_contracts/__init__.py +642 -0
  28. handler_contracts/account.py +36 -0
  29. handler_contracts/acp_endpoint.py +69 -0
  30. handler_contracts/addon.py +147 -0
  31. handler_contracts/agent_spec.py +75 -0
  32. handler_contracts/automation.py +285 -0
  33. handler_contracts/common.py +229 -0
  34. handler_contracts/control.py +1208 -0
  35. handler_contracts/harness.py +300 -0
  36. handler_contracts/miniapp.py +141 -0
  37. handler_contracts/provider.py +82 -0
  38. handler_contracts/remote.py +167 -0
  39. handler_contracts/sandbox.py +199 -0
  40. handler_contracts/schemas.py +313 -0
  41. handler_contracts/terminal.py +32 -0
  42. handler_control/__init__.py +7 -0
  43. handler_control/access.py +62 -0
  44. handler_control/accounts.py +279 -0
  45. handler_control/addon_install.py +268 -0
  46. handler_control/addons.py +118 -0
  47. handler_control/agent_state.py +46 -0
  48. handler_control/agents.py +257 -0
  49. handler_control/api/__init__.py +137 -0
  50. handler_control/api/accounts.py +231 -0
  51. handler_control/api/acp.py +116 -0
  52. handler_control/api/addons.py +95 -0
  53. handler_control/api/agents.py +150 -0
  54. handler_control/api/apps.py +104 -0
  55. handler_control/api/automations.py +128 -0
  56. handler_control/api/bindings.py +105 -0
  57. handler_control/api/builtin_agents.py +22 -0
  58. handler_control/api/caches.py +79 -0
  59. handler_control/api/catalog.py +197 -0
  60. handler_control/api/common.py +205 -0
  61. handler_control/api/contexts.py +88 -0
  62. handler_control/api/folders.py +51 -0
  63. handler_control/api/images.py +164 -0
  64. handler_control/api/inbox.py +21 -0
  65. handler_control/api/projects.py +170 -0
  66. handler_control/api/providers.py +107 -0
  67. handler_control/api/remote.py +45 -0
  68. handler_control/api/secrets.py +177 -0
  69. handler_control/api/sessions.py +192 -0
  70. handler_control/api/skills.py +270 -0
  71. handler_control/app_api.py +84 -0
  72. handler_control/app_http.py +214 -0
  73. handler_control/app_remote.py +206 -0
  74. handler_control/apps.py +867 -0
  75. handler_control/auth.py +48 -0
  76. handler_control/automation_config.py +255 -0
  77. handler_control/automations.py +1134 -0
  78. handler_control/bindings.py +149 -0
  79. handler_control/builds.py +341 -0
  80. handler_control/builtin_agents.py +82 -0
  81. handler_control/caches.py +113 -0
  82. handler_control/checks.py +287 -0
  83. handler_control/compose.py +317 -0
  84. handler_control/config_files.py +41 -0
  85. handler_control/contexts.py +75 -0
  86. handler_control/daemon.py +64 -0
  87. handler_control/data/automation/bin/handler-llm +85 -0
  88. handler_control/data/automations/env-issues/automation.toml +17 -0
  89. handler_control/data/automations/env-issues/extract.py +73 -0
  90. handler_control/data/automations/env-issues/findings.schema.json +28 -0
  91. handler_control/data/automations/env-issues/prompt.md +19 -0
  92. handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
  93. handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
  94. handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
  95. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
  96. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
  97. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
  98. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
  99. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
  100. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
  101. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  102. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  103. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
  104. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
  105. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  106. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
  107. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
  108. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
  109. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
  110. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
  111. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
  112. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
  113. handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
  114. handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
  115. handler_control/data/builtin-agents/skill-review/interface.json +83 -0
  116. handler_control/data/interfaces/llm/v1/interface.toml +17 -0
  117. handler_control/data/interfaces/llm/v1/tools.json +138 -0
  118. handler_control/data/templates/README.md +10 -0
  119. handler_control/data/templates/providers/anthropic-api.toml +18 -0
  120. handler_control/data/templates/providers/llamacpp.toml +210 -0
  121. handler_control/data/templates/providers/mistral-api.toml +14 -0
  122. handler_control/data/templates/providers/ollama.toml +10 -0
  123. handler_control/data/templates/providers/openai-api.toml +13 -0
  124. handler_control/data/templates/providers/openrouter.toml +10 -0
  125. handler_control/desktop.py +68 -0
  126. handler_control/display.py +62 -0
  127. handler_control/files.py +23 -0
  128. handler_control/folders.py +68 -0
  129. handler_control/git_read.py +39 -0
  130. handler_control/image_check.py +322 -0
  131. handler_control/image_updates.py +174 -0
  132. handler_control/inbox.py +70 -0
  133. handler_control/interfaces.py +151 -0
  134. handler_control/knowledge.py +237 -0
  135. handler_control/launcher.py +356 -0
  136. handler_control/llm.py +194 -0
  137. handler_control/migrations/__init__.py +62 -0
  138. handler_control/policy.py +305 -0
  139. handler_control/projects.py +266 -0
  140. handler_control/providers.py +180 -0
  141. handler_control/relay.py +133 -0
  142. handler_control/remote.py +888 -0
  143. handler_control/remote_crypto.py +97 -0
  144. handler_control/runtime.py +140 -0
  145. handler_control/sandbox_process.py +224 -0
  146. handler_control/schedules.py +58 -0
  147. handler_control/secrets.py +242 -0
  148. handler_control/service_router.py +180 -0
  149. handler_control/services.py +92 -0
  150. handler_control/session_files.py +429 -0
  151. handler_control/sessions.py +1116 -0
  152. handler_control/settings.py +129 -0
  153. handler_control/shortcuts.py +80 -0
  154. handler_control/skill_scan.py +88 -0
  155. handler_control/skill_sources.py +61 -0
  156. handler_control/skills.py +378 -0
  157. handler_control/terminal.py +79 -0
  158. handler_control/terminals.py +108 -0
  159. handler_control/transcript.py +174 -0
  160. handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
  161. handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
  162. handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  163. handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  164. handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  165. handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  166. handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  167. handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  168. handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  169. handler_control/web-ui/favicon.svg +1 -0
  170. handler_control/web-ui/index.html +15 -0
  171. handler_control/webpush.py +90 -0
  172. handler_control/webui.py +105 -0
  173. handler_harness/__init__.py +13 -0
  174. handler_harness/addons.py +129 -0
  175. handler_harness/catalog.py +116 -0
  176. handler_harness/cli.py +109 -0
  177. handler_harness/data/addons/README.md +44 -0
  178. handler_harness/data/addons/miniapp/Containerfile +20 -0
  179. handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
  180. handler_harness/data/addons/miniapp/addon.toml +16 -0
  181. handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
  182. handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
  183. handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
  184. handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
  185. handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
  186. handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
  187. handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
  188. handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
  189. handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
  190. handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
  191. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
  192. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
  193. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
  194. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
  195. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
  196. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
  197. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
  198. handler_harness/data/catalog/README.md +12 -0
  199. handler_harness/data/catalog/playwright/Containerfile +28 -0
  200. handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
  201. handler_harness/data/catalog/playwright/addon.toml +25 -0
  202. handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
  203. handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
  204. handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
  205. handler_harness/data/catalog/shopify/Containerfile +22 -0
  206. handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
  207. handler_harness/data/catalog/shopify/addon.toml +39 -0
  208. handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
  209. handler_harness/data/handler-sandbox-env +30 -0
  210. handler_harness/data/harnesses/README.md +47 -0
  211. handler_harness/data/harnesses/claude-code/Containerfile +27 -0
  212. handler_harness/data/harnesses/claude-code/harness.toml +95 -0
  213. handler_harness/data/harnesses/codex/Containerfile +26 -0
  214. handler_harness/data/harnesses/codex/harness.toml +103 -0
  215. handler_harness/data/harnesses/pi/Containerfile +27 -0
  216. handler_harness/data/harnesses/pi/harness.toml +65 -0
  217. handler_harness/data/harnesses/vibe/Containerfile +19 -0
  218. handler_harness/data/harnesses/vibe/harness.toml +96 -0
  219. handler_harness/data/toolbox/Containerfile +56 -0
  220. handler_harness/data/toolbox/README.md +24 -0
  221. handler_harness/data/toolbox/toolbox.toml +41 -0
  222. handler_harness/images.py +62 -0
  223. handler_harness/providers.py +300 -0
  224. handler_harness/render.py +472 -0
  225. handler_sandbox_podman/__init__.py +1 -0
  226. handler_sandbox_podman/caches.py +97 -0
  227. handler_sandbox_podman/check.py +121 -0
  228. handler_sandbox_podman/cli.py +239 -0
  229. handler_sandbox_podman/egress.py +118 -0
  230. handler_sandbox_podman/images.py +199 -0
  231. handler_sandbox_podman/launch.py +474 -0
  232. handler_sandbox_podman/podman.py +108 -0
  233. handler_sandbox_podman/proxy/Containerfile +6 -0
  234. handler_sandbox_podman/proxy/addon.py +255 -0
@@ -0,0 +1,429 @@
1
+ """A session's folder as its people see it (F.8): its folders and files, read without following a
2
+ link nor leaving the folder, and what changed in it since the session started.
3
+
4
+ The agent writes in that folder, so nothing in it is trusted: every path is opened one name at a
5
+ time from the folder itself, never following a link; only regular files are read, up to a size;
6
+ `.gitignore` files are read the same way. Git objects are read by `git_read` in a child process
7
+ (a repository is the agent's too: handlerd never runs git nor reads `.git` itself).
8
+
9
+ The baseline, recorded when the session first starts (`baseline.json` in its folder, F.2): each
10
+ file's size and modification time, and the commit HEAD was at when the folder is a git repository
11
+ (the earlier versions of the files are that commit's). Files ignored by the folder's `.gitignore`
12
+ files (and `.git/info/exclude`) are left out, `.git` folders always.
13
+
14
+ What else of the host its sandbox sees is recorded at each start (`mounts.json`): knowledge
15
+ folders, skills and a run's subject are read here like its folder (the agent cannot change them);
16
+ the agent's own state (it holds the account's sign-in) and caches are only listed."""
17
+
18
+ import base64
19
+ import errno
20
+ import json
21
+ import os
22
+ import stat
23
+ import subprocess
24
+ import sys
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+
28
+ from dulwich.ignore import IgnoreFilter
29
+
30
+ from handler_contracts import (
31
+ SandboxSpec,
32
+ SessionChanges,
33
+ SessionFile,
34
+ SessionFileChange,
35
+ SessionFolder,
36
+ SessionFolderEntry,
37
+ SessionFound,
38
+ SessionMount,
39
+ SessionMounts,
40
+ )
41
+
42
+ from .files import write_private
43
+
44
+ MAX_FILES = 50_000 # beyond, changes are not followed
45
+ MAX_READ = 1 << 20 # a file shown, a .gitignore read
46
+ GIT_TIMEOUT = 10.0
47
+ IMAGES = {
48
+ b"\x89PNG\r\n\x1a\n": "image/png",
49
+ b"\xff\xd8\xff": "image/jpeg",
50
+ b"GIF87a": "image/gif",
51
+ b"GIF89a": "image/gif",
52
+ }
53
+ DIR = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
54
+ FILE = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC # a FIFO does not block
55
+
56
+ BROWSABLE = ("scope", "knowledge", "subject", "config")
57
+
58
+ Stats = dict[str, list[int]] # path → [size, mtime_ns]
59
+ Chain = list[tuple[str, IgnoreFilter]] # (folder, its ignore rules), deepest first
60
+
61
+
62
+ class PathError(Exception):
63
+ """A path that cannot be shown: `missing` (404) or not one to show (400)."""
64
+
65
+ def __init__(self, message: str, missing: bool = False) -> None:
66
+ super().__init__(message)
67
+ self.missing = missing
68
+
69
+
70
+ class TooManyFilesError(Exception):
71
+ pass
72
+
73
+
74
+ # ── what the sandbox sees ───────────────────────────────────────────────
75
+
76
+
77
+ def record_mounts(recorded: Path, spec: SandboxSpec, at: datetime) -> None:
78
+ mounts = [
79
+ SessionMount(
80
+ target=m.target,
81
+ kind=m.kind,
82
+ read_only=m.read_only,
83
+ source=m.source,
84
+ browsable=m.kind in BROWSABLE,
85
+ )
86
+ for m in spec.mounts
87
+ ]
88
+ mounts += [
89
+ SessionMount(target=i.target, kind="tools", read_only=True, source=i.image, browsable=False)
90
+ for i in spec.image_mounts
91
+ ]
92
+ found = SessionMounts(started_at=at, mounts=mounts)
93
+ write_private(recorded, found.model_dump_json(by_alias=True, exclude_none=True))
94
+
95
+
96
+ def mounts(recorded: Path, scope: str) -> SessionMounts:
97
+ try:
98
+ return SessionMounts.model_validate_json(recorded.read_text())
99
+ except FileNotFoundError: # never started yet
100
+ folder = SessionMount(
101
+ target=scope, kind="scope", read_only=False, source=scope, browsable=True
102
+ )
103
+ return SessionMounts(mounts=[folder])
104
+
105
+
106
+ def mount_root(recorded: Path, scope: str, target: str | None) -> tuple[Path, bool]:
107
+ """(its folder on the host, whether it is the session's folder) of the browsable mount the
108
+ agent sees at `target`; the session's folder without one."""
109
+ if target is None:
110
+ return Path(scope), True
111
+ for m in mounts(recorded, scope).mounts:
112
+ if m.target == target and m.browsable:
113
+ return Path(m.source), m.kind == "scope"
114
+ raise PathError(f"the agent sees nothing to show at {target}", missing=True)
115
+
116
+
117
+ # ── reading ─────────────────────────────────────────────────────────────
118
+
119
+
120
+ def folder(root: Path, relative: str) -> SessionFolder:
121
+ parts = _parts(relative)
122
+ fd = _open_folder(root, parts)
123
+ try:
124
+ with os.scandir(fd) as found:
125
+ scanned = list(found)
126
+ entries: list[SessionFolderEntry] = []
127
+ for entry in scanned:
128
+ if entry.name == ".git":
129
+ continue
130
+ try:
131
+ st = entry.stat(follow_symlinks=False)
132
+ target = os.readlink(entry.name, dir_fd=fd) if entry.is_symlink() else None
133
+ except FileNotFoundError: # gone meanwhile
134
+ continue
135
+ if stat.S_ISDIR(st.st_mode):
136
+ entries.append(SessionFolderEntry(name=entry.name, kind="folder"))
137
+ elif stat.S_ISREG(st.st_mode):
138
+ entries.append(SessionFolderEntry(name=entry.name, kind="file", size=st.st_size))
139
+ elif target is not None:
140
+ entries.append(SessionFolderEntry(name=entry.name, kind="link", target=target))
141
+ finally:
142
+ os.close(fd)
143
+ entries.sort(key=lambda e: (e.kind != "folder", e.name.lower(), e.name))
144
+ return SessionFolder(path="/".join(parts), entries=entries)
145
+
146
+
147
+ def read(root: Path, relative: str, baseline: Path | None) -> SessionFile:
148
+ """A file, with what changed in it since the session started (with its `baseline`, for the
149
+ session's folder); a deleted one is shown with its earlier version when there is one."""
150
+ parts = _parts(relative)
151
+ if not parts:
152
+ raise PathError("not a file")
153
+ path = "/".join(parts)
154
+ recorded = _load(baseline) if baseline else {}
155
+ before_stats = recorded.get("files", {}).get(path)
156
+ commit = recorded.get("commit")
157
+ try:
158
+ data, size, mtime = _read_file(root, parts)
159
+ except PathError as e:
160
+ if not (e.missing and before_stats):
161
+ raise
162
+ before = git_text(root, commit, path) if commit else None
163
+ return SessionFile(path=path, change="deleted", before=before)
164
+ change = None
165
+ if "files" in recorded:
166
+ change = "added" if before_stats is None else None
167
+ if before_stats is not None and before_stats != [size, mtime]:
168
+ change = "modified"
169
+ view = SessionFile(path=path, size=size, change=change, **_shown(data, size))
170
+ if change == "modified" and commit:
171
+ view = view.model_copy(update={"before": git_text(root, commit, path)})
172
+ return view
173
+
174
+
175
+ def host_path(root: Path, relative: str) -> tuple[Path, bool]:
176
+ """(where it is on the host, whether it is a folder): checked like a read, inside the folder
177
+ and through no link on the way."""
178
+ parts = _parts(relative)
179
+ if not parts:
180
+ return root, True
181
+ fd = _open_folder(root, parts[:-1])
182
+ try:
183
+ st = os.stat(parts[-1], dir_fd=fd, follow_symlinks=False)
184
+ except FileNotFoundError:
185
+ raise PathError(f"no file {'/'.join(parts)}", missing=True) from None
186
+ finally:
187
+ os.close(fd)
188
+ return root.joinpath(*parts), stat.S_ISDIR(st.st_mode)
189
+
190
+
191
+ def _shown(data: bytes, size: int) -> dict[str, str]:
192
+ if size > MAX_READ:
193
+ return {}
194
+ for magic, mime in IMAGES.items():
195
+ if data.startswith(magic):
196
+ return {"image": f"data:{mime};base64,{base64.b64encode(data).decode()}"}
197
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
198
+ return {"image": f"data:image/webp;base64,{base64.b64encode(data).decode()}"}
199
+ return {"text": text} if (text := _text(data)) is not None else {}
200
+
201
+
202
+ def _text(data: bytes) -> str | None:
203
+ if b"\0" in data:
204
+ return None
205
+ try:
206
+ return data.decode()
207
+ except UnicodeDecodeError:
208
+ return None
209
+
210
+
211
+ def _parts(relative: str) -> list[str]:
212
+ parts = [p for p in relative.split("/") if p]
213
+ if relative.startswith("/") or "\0" in relative or any(p in (".", "..") for p in parts):
214
+ raise PathError("path invalid: relative to the session's folder, without . or ..")
215
+ if ".git" in parts:
216
+ raise PathError("a .git folder is not shown")
217
+ return parts
218
+
219
+
220
+ def _open_folder(root: Path, parts: list[str]) -> int:
221
+ """The folder at `parts`, opened one name at a time from `root`, never through a link."""
222
+ fd = os.open(root, DIR & ~os.O_NOFOLLOW) # the session's folder itself: handlerd's to trust
223
+ opened: list[str] = []
224
+ try:
225
+ for name in parts:
226
+ opened.append(name)
227
+ sub = os.open(name, DIR, dir_fd=fd)
228
+ os.close(fd)
229
+ fd = sub
230
+ except OSError as e:
231
+ link = e.errno in (errno.ELOOP, errno.ENOTDIR) and _is_link(fd, opened[-1])
232
+ os.close(fd)
233
+ path = "/".join(opened)
234
+ if link:
235
+ raise PathError(f"{path} is a link: links are not followed") from None
236
+ if e.errno in (errno.ENOENT, errno.ENOTDIR):
237
+ raise PathError(f"no folder {path}", missing=True) from None
238
+ raise
239
+ return fd
240
+
241
+
242
+ def _is_link(fd: int, name: str) -> bool:
243
+ try:
244
+ return stat.S_ISLNK(os.stat(name, dir_fd=fd, follow_symlinks=False).st_mode)
245
+ except OSError:
246
+ return False
247
+
248
+
249
+ def _read_file(root: Path, parts: list[str]) -> tuple[bytes, int, int]:
250
+ """(its first MAX_READ + 1 bytes, size, mtime_ns) of a regular file."""
251
+ path = "/".join(parts)
252
+ fd = _open_folder(root, parts[:-1])
253
+ try:
254
+ file = os.open(parts[-1], FILE, dir_fd=fd)
255
+ except OSError as e:
256
+ if e.errno == errno.ENOENT:
257
+ raise PathError(f"no file {path}", missing=True) from None
258
+ if e.errno == errno.ELOOP:
259
+ raise PathError(f"{path} is a link: links are not followed") from None
260
+ raise
261
+ finally:
262
+ os.close(fd)
263
+ with os.fdopen(file, "rb") as f:
264
+ st = os.fstat(f.fileno())
265
+ if not stat.S_ISREG(st.st_mode):
266
+ raise PathError(f"{path} is not a file")
267
+ return f.read(MAX_READ + 1), st.st_size, st.st_mtime_ns
268
+
269
+
270
+ # ── changes ─────────────────────────────────────────────────────────────
271
+
272
+
273
+ def record_baseline(root: Path, baseline: Path) -> None:
274
+ """The folder as the session starts (see above)."""
275
+ try:
276
+ recorded: dict[str, object] = {"files": _walk(root), "commit": git_head(root)}
277
+ except TooManyFilesError:
278
+ recorded = {"notFollowed": f"its folder has more than {MAX_FILES} files"}
279
+ write_private(baseline, json.dumps(recorded))
280
+
281
+
282
+ def changes(root: Path, baseline: Path, since: datetime) -> SessionChanges:
283
+ recorded = _load(baseline)
284
+ git = bool(recorded.get("commit"))
285
+ if "files" not in recorded:
286
+ reason = recorded.get("notFollowed", "its folder was not recorded when it started")
287
+ return SessionChanges(since=since, git=git, not_followed=reason)
288
+ try:
289
+ now = _walk(root)
290
+ except TooManyFilesError:
291
+ reason = f"its folder has more than {MAX_FILES} files"
292
+ return SessionChanges(since=since, git=git, not_followed=reason)
293
+ before: Stats = recorded["files"]
294
+ found = [
295
+ SessionFileChange(
296
+ path=path,
297
+ change="added" if path not in before else "modified",
298
+ at=datetime.fromtimestamp(stats[1] / 1e9, UTC),
299
+ )
300
+ for path, stats in now.items()
301
+ if before.get(path) != stats
302
+ ]
303
+ found.sort(key=lambda c: c.at or since, reverse=True)
304
+ deleted = [SessionFileChange(path=p, change="deleted") for p in sorted(before) if p not in now]
305
+ return SessionChanges(since=since, git=git, files=found + deleted)
306
+
307
+
308
+ def find(root: Path, query: str, limit: int = 20) -> SessionFound:
309
+ """The files whose path holds the query's letters in order, case aside: those whose name
310
+ starts with it first, then whose name, then whose path holds it, then the others; the shorter
311
+ path first. No query: the most recently modified. Ignored files and `.git` are not searched."""
312
+ try:
313
+ files = _walk(root)
314
+ except TooManyFilesError:
315
+ return SessionFound(paths=[], not_followed=f"its folder has more than {MAX_FILES} files")
316
+ q = query.lower()
317
+ if not q:
318
+ recent = sorted(files, key=lambda p: files[p][1], reverse=True)
319
+ return SessionFound(paths=recent[:limit])
320
+ ranked = [(rank, len(p), p) for p in files if (rank := _rank(p.lower(), q)) is not None]
321
+ return SessionFound(paths=[p for _, _, p in sorted(ranked)[:limit]])
322
+
323
+
324
+ def _rank(path: str, q: str) -> int | None:
325
+ name = path.rsplit("/", 1)[-1]
326
+ if name.startswith(q):
327
+ return 0
328
+ if q in name:
329
+ return 1
330
+ if q in path:
331
+ return 2
332
+ letters = iter(path)
333
+ return 3 if all(c in letters for c in q) else None
334
+
335
+
336
+ def _load(baseline: Path) -> dict:
337
+ try:
338
+ return json.loads(baseline.read_text())
339
+ except FileNotFoundError:
340
+ return {}
341
+
342
+
343
+ def _walk(root: Path) -> Stats:
344
+ """Every regular file and link (not followed) of the folder, but ignored ones and `.git`."""
345
+ found: Stats = {}
346
+ exclude = _rules(root, [".git", "info"], "exclude")
347
+ pending: list[tuple[list[str], Chain]] = [([], [("", exclude)] if exclude else [])]
348
+ while pending:
349
+ parts, chain = pending.pop()
350
+ try:
351
+ fd = _open_folder(root, parts)
352
+ except PathError: # replaced meanwhile
353
+ continue
354
+ try:
355
+ if own := _rules(root, parts, ".gitignore"):
356
+ chain = [("/".join(parts), own), *chain]
357
+ with os.scandir(fd) as entries:
358
+ for entry in entries:
359
+ if entry.name == ".git":
360
+ continue
361
+ path = "/".join([*parts, entry.name])
362
+ try:
363
+ st = entry.stat(follow_symlinks=False)
364
+ except FileNotFoundError:
365
+ continue
366
+ if stat.S_ISDIR(st.st_mode):
367
+ if not _ignored(chain, path + "/"):
368
+ pending.append(([*parts, entry.name], chain))
369
+ elif _followed(st.st_mode) and not _ignored(chain, path):
370
+ found[path] = [st.st_size, st.st_mtime_ns]
371
+ if len(found) > MAX_FILES:
372
+ raise TooManyFilesError
373
+ finally:
374
+ os.close(fd)
375
+ return found
376
+
377
+
378
+ def _followed(mode: int) -> bool:
379
+ return stat.S_ISREG(mode) or stat.S_ISLNK(mode)
380
+
381
+
382
+ def _rules(root: Path, parts: list[str], name: str) -> IgnoreFilter | None:
383
+ try:
384
+ data, size, _ = _read_file(root, [*parts, name])
385
+ except (PathError, OSError):
386
+ return None
387
+ if size > MAX_READ:
388
+ return None
389
+ return IgnoreFilter(data.splitlines())
390
+
391
+
392
+ def _ignored(chain: Chain, path: str) -> bool:
393
+ """git's rules: the deepest `.gitignore` that has a say decides."""
394
+ for base, rules in chain:
395
+ verdict = rules.is_ignored(path[len(base) + 1 :] if base else path)
396
+ if verdict is not None:
397
+ return verdict
398
+ return False
399
+
400
+
401
+ # ── git, in a child process ─────────────────────────────────────────────
402
+
403
+
404
+ def git_head(root: Path) -> str | None:
405
+ """The commit the folder's repository is at; None outside a repository or before a
406
+ commit."""
407
+ if not (root / ".git").is_dir():
408
+ return None
409
+ out = _git_read("head", str(root))
410
+ return out.decode() if out else None
411
+
412
+
413
+ def git_text(root: Path, commit: str, path: str) -> str | None:
414
+ """`path`'s text at `commit`; None when it was not there, or not text."""
415
+ out = _git_read("show", str(root), commit, path)
416
+ return _text(out) if out is not None and len(out) <= MAX_READ else None
417
+
418
+
419
+ def _git_read(*args: str) -> bytes | None:
420
+ try:
421
+ done = subprocess.run(
422
+ [sys.executable, "-m", "handler_control.git_read", *args],
423
+ capture_output=True,
424
+ timeout=GIT_TIMEOUT,
425
+ check=False,
426
+ )
427
+ except subprocess.TimeoutExpired:
428
+ return None
429
+ return done.stdout if done.returncode == 0 else None