alissa-tools-github-devloop 0.8.22__tar.gz → 0.8.24__tar.gz

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 (37) hide show
  1. {alissa_tools_github_devloop-0.8.22/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.24}/PKG-INFO +1 -1
  2. alissa_tools_github_devloop-0.8.24/src/main/alissa/tools/github/devloop/hubs.py +427 -0
  3. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/loop.py +547 -3
  4. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/loop_events.py +3 -1
  5. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/state.py +71 -6
  6. alissa_tools_github_devloop-0.8.24/src/main/alissa/tools/github/devloop/version +1 -0
  7. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  8. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  9. alissa_tools_github_devloop-0.8.22/src/main/alissa/tools/github/devloop/version +0 -1
  10. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/LICENSE +0 -0
  11. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/MANIFEST.in +0 -0
  12. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/NOTICE +0 -0
  13. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/README.md +0 -0
  14. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/requirements.txt +0 -0
  15. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/setup.cfg +0 -0
  16. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/setup.py +0 -0
  17. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  18. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  19. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  20. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  21. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  22. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/config.py +0 -0
  23. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  24. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  25. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  26. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  27. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/version.py +0 -0
  28. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  31. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  32. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  33. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  34. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  35. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  36. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  37. {alissa_tools_github_devloop-0.8.22 → alissa_tools_github_devloop-0.8.24}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.22
3
+ Version: 0.8.24
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -0,0 +1,427 @@
1
+ """Hub trust: keeping Claude Code's per-directory "trust this folder?" gate
2
+ pre-accepted for every directory a dev session may start in (issue #123).
3
+
4
+ Claude Code asks, once per directory, whether the folder it was started in is
5
+ trusted -- and `--dangerously-skip-permissions` does NOT suppress that
6
+ dialog. A headless session that meets it hangs: the directive `alissa tmux
7
+ queue` types into the pane is swallowed by the prompt, the session sits in
8
+ the roster reading ALIVE, and the daemon's liveness discipline (correctly)
9
+ declines to respawn over a listed session. Observed 2026-09-16 on
10
+ `dark-devloop-shared`: 108 minutes on `ali-fhr/docs.alissa.app#10`, until an
11
+ operator accepted the dialog by hand.
12
+
13
+ The container entrypoint seeds the gate at boot for the hubs it can NAME at
14
+ boot: `{root}/{basename}` for every entry in `ALISSA_DEV_REPOS`, plus every
15
+ hub already on disk. Under `repos_source: bows` `ALISSA_DEV_REPOS` is empty
16
+ -- the allowlist is DERIVED from the feed Bodies of Work at runtime -- so a
17
+ repo hub-ified after boot was never trusted. This module closes that gap
18
+ from the daemon's side, in three places the loop calls:
19
+
20
+ * after every successful feed refresh: the derived list is written to
21
+ `{root}/.alissa-derived-repos` (`DERIVED_REPOS_FILENAME`) so the NEXT boot's
22
+ entrypoint seeding can read it, and the derived hubs are trusted right
23
+ away, hub-ified or not (trusting a path that does not exist yet is exactly
24
+ what the entrypoint already does for the static allowlist);
25
+ * at hub-ify time and before every spawn (`DevWatcher._ensure_hub`): the hub
26
+ root, `{hub}/main`, and every `{hub}/TASK-*` worktree on disk;
27
+ * after a first-run-dialog wedge kill: the hub the wedged session started in,
28
+ so the re-enqueued attempt does not meet the same prompt.
29
+
30
+ The merge is the entrypoint's, transposed: load-then-update of
31
+ `~/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude.json` (when set --
32
+ `CLAUDE_CONFIG_DIR` reliably relocates only the credential file, so both
33
+ targets are written and whichever claude reads carries the flag), only ever
34
+ ADDING `projects[<path>].hasTrustDialogAccepted = true`. Idempotent: a target
35
+ that already carries every entry is not rewritten at all, which is also what
36
+ keeps the daemon's writes off a file Claude Code itself rewrites -- the only
37
+ time this module touches the file is when a NEW directory has to be
38
+ trusted. That write is guarded on three sides, because Claude Code itself
39
+ rewrites the same file while sessions run (`_trust_hub` runs before EVERY
40
+ spawn, and the `TASK-*` glob makes most of those writes real ones): the
41
+ temp-file + rename keeps a concurrent READER from ever seeing a torn file;
42
+ a compare-and-swap immediately before the rename (the file must still hold
43
+ the bytes the merge was computed from) keeps the OTHER WRITER's update
44
+ from being discarded wholesale -- the payload is the whole file, so a lost
45
+ update could drop another session's `projects` entries or the persisted
46
+ login; and a re-read after the rename catches a writer that landed right
47
+ after ours and dropped OUR entries. Either miss restarts the merge on top
48
+ of the newer file, a bounded number of times, and giving up is one WARNING.
49
+ Never raises: trust is a convenience for the session about to spawn, and a
50
+ failed write is one WARNING, never a lost poll.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import json
56
+ import logging
57
+ import os
58
+ import re
59
+ from pathlib import Path
60
+ from typing import Iterable, Sequence
61
+
62
+ log = logging.getLogger(__name__)
63
+
64
+ # The file the daemon writes the derived (bows-mode) allowlist to, under the
65
+ # workspace root, one `owner/repo` per line -- and the file the entrypoint's
66
+ # first-run seeding reads at the NEXT boot (`ALISSA_DERIVED_REPOS_FILE`
67
+ # overrides the location on both sides).
68
+ DERIVED_REPOS_FILENAME = ".alissa-derived-repos"
69
+ ENV_DERIVED_REPOS_FILE = "ALISSA_DERIVED_REPOS_FILE"
70
+
71
+ # The state file Claude Code keeps its per-directory trust in, relative to
72
+ # $HOME and to $CLAUDE_CONFIG_DIR.
73
+ CLAUDE_STATE_FILENAME = ".claude.json"
74
+ ENV_CLAUDE_CONFIG_DIR = "CLAUDE_CONFIG_DIR"
75
+ TRUST_KEY = "hasTrustDialogAccepted"
76
+
77
+ # What a pane PARKED on one of Claude Code's first-run gates shows -- and
78
+ # what corroborates that it is parked there rather than merely mentioning
79
+ # it (PR #124 review round 1). The gates' words are quoted by this repo's
80
+ # README, CHANGELOG, this module and issue #123, so a session that cats any
81
+ # of them, greps the tree or shows its own diff has them on screen while at
82
+ # work -- and a match kills the session. The classifier therefore asks for
83
+ # the gate's SHAPE, not its vocabulary (see `pane_shows_first_run_dialog`):
84
+ #
85
+ # * the gate's ACCEPT option (`FIRST_RUN_DIALOG_OPTIONS`) is one of the last
86
+ # `FIRST_RUN_DIALOG_TAIL_LINES` non-blank lines of the capture, once the
87
+ # selection cursor, option numbering and box drawing are stripped;
88
+ # * nothing follows it but the gate's own chrome (`FIRST_RUN_DIALOG_CHROME`):
89
+ # a session that printed ANYTHING after the text -- a spinner, a tool
90
+ # call, the `>` input prompt an idle session sits at, a shell prompt --
91
+ # is not on the gate;
92
+ # * the gate's QUESTION (`FIRST_RUN_DIALOG_MARKERS`) appears at or above
93
+ # the option line.
94
+ #
95
+ # The trust dialog reads "Quick safety check: Is this a project you created
96
+ # or one you trust? … ❯ No, exit / Yes, I trust this folder"; the
97
+ # bypass-permissions gate reads "WARNING: Claude Code running in Bypass
98
+ # Permissions mode … No, exit / Yes, I accept" (either option order, with
99
+ # or without numbering, an optional "Enter to confirm · Esc to exit" footer
100
+ # and box drawing). Both are gates the entrypoint's seeding is meant to
101
+ # pre-answer, so a pane parked on either is a session the seeding missed,
102
+ # not a session at work.
103
+ FIRST_RUN_DIALOG_MARKERS = (
104
+ "trust this folder",
105
+ "bypass permissions mode",
106
+ )
107
+ FIRST_RUN_DIALOG_OPTIONS = (
108
+ "yes, i trust this folder",
109
+ "yes, i accept",
110
+ )
111
+ FIRST_RUN_DIALOG_CHROME = (
112
+ "no, exit",
113
+ "enter to confirm",
114
+ )
115
+ FIRST_RUN_DIALOG_TAIL_LINES = 6
116
+
117
+ # What a gate draws AROUND its text: box drawing, the selection cursor and
118
+ # whitespace (stripped from both ends of a line), then "1." / "2)" option
119
+ # numbering. Nothing else is stripped -- a diff's `+`, a markdown bullet,
120
+ # a quote's backtick and the `>` of Claude Code's input prompt all stay,
121
+ # which is what keeps a line that merely quotes a gate from reading as one.
122
+ _DIALOG_DECORATION = "❯ \t│┃║╭╮╰╯┌┐└┘├┤┬┴┼─━═"
123
+ _DIALOG_NUMBERING = re.compile(r"^\d+[.)]\s*")
124
+
125
+ # How many times `seed_trust` restarts a merge that another writer moved
126
+ # the file under (see the module docstring) before it gives up with a
127
+ # WARNING. Claude Code's own rewrites are bursts around a session's start
128
+ # and end, not a stream, so a second pass is expected to land.
129
+ SEED_TRUST_ATTEMPTS = 3
130
+
131
+ # The wedge classification the loop logs and records for a stale session
132
+ # whose pane shows one of those gates.
133
+ WEDGE_FIRST_RUN_DIALOG = "wedged:first-run-dialog"
134
+
135
+
136
+ def claude_state_targets(
137
+ home: "Path | str | None" = None, config_dir: "Path | str | None" = None
138
+ ) -> "list[Path]":
139
+ """The `.claude.json` files to merge trust into: `$HOME/.claude.json`,
140
+ plus `$CLAUDE_CONFIG_DIR/.claude.json` when that variable is set and
141
+ non-blank. Both arguments default to the environment (`~` expansion
142
+ honours `HOME`), which is what lets a test point the merge at scratch
143
+ directories without touching the daemon's own files."""
144
+ home_dir = Path(home) if home is not None else Path(os.path.expanduser("~"))
145
+ targets = [home_dir / CLAUDE_STATE_FILENAME]
146
+ raw = (
147
+ str(config_dir)
148
+ if config_dir is not None
149
+ else os.environ.get(ENV_CLAUDE_CONFIG_DIR, "")
150
+ ).strip()
151
+ if raw:
152
+ extra = Path(raw) / CLAUDE_STATE_FILENAME
153
+ if extra != targets[0]:
154
+ targets.append(extra)
155
+ return targets
156
+
157
+
158
+ def hub_trust_paths(hub: "Path | str") -> "list[Path]":
159
+ """Every directory a session on this hub may START in: the hub root
160
+ (where develop/fix/maintain sessions are spawned), `{hub}/main`, and each
161
+ `{hub}/TASK-*` worktree already on disk (a retried or resumed attempt may
162
+ be started inside a surviving worktree). The root and `main` are listed
163
+ whether or not they exist yet -- the point of seeding is to be there
164
+ BEFORE the directory is used."""
165
+ root = Path(hub)
166
+ paths = [root, root / "main"]
167
+ try:
168
+ worktrees = sorted(
169
+ p for p in root.glob("TASK-*") if p.is_dir()
170
+ )
171
+ except OSError:
172
+ worktrees = []
173
+ paths.extend(worktrees)
174
+ return paths
175
+
176
+
177
+ def _load_state(path: Path) -> "tuple[str | None, dict]":
178
+ """The state file's raw text (None when absent) and its parsed object
179
+ (`{}` when absent or not a JSON object). Only ABSENCE is folded into
180
+ "start fresh": an unparseable file raises `ValueError` up to
181
+ `seed_trust`, which skips that target rather than replacing it (a
182
+ corrupt state file is never silently overwritten). The raw text is what
183
+ `_write_atomic` compares against before its rename."""
184
+ try:
185
+ with open(path, encoding="utf-8") as fh:
186
+ raw = fh.read()
187
+ except FileNotFoundError:
188
+ return None, {}
189
+ data = json.loads(raw)
190
+ return raw, (data if isinstance(data, dict) else {})
191
+
192
+
193
+ def _raw_text(path: Path) -> "str | None":
194
+ try:
195
+ return path.read_text(encoding="utf-8")
196
+ except FileNotFoundError:
197
+ return None
198
+
199
+
200
+ def _write_atomic(path: Path, data: dict, expected: "str | None") -> bool:
201
+ """Write `data` over `path` by temp file + rename -- unless the file no
202
+ longer holds `expected` (the raw text the caller merged from), in which
203
+ case nothing is replaced and False is returned so the caller merges
204
+ again on top of whoever wrote in between. The rename keeps READERS
205
+ safe (never a torn file); the check before it keeps the OTHER WRITER's
206
+ update safe, down to the gap between the check and the rename."""
207
+ path.parent.mkdir(parents=True, exist_ok=True)
208
+ tmp = path.with_name(path.name + ".tmp")
209
+ with open(tmp, "w", encoding="utf-8") as fh:
210
+ json.dump(data, fh, indent=2)
211
+ fh.write("\n")
212
+ if _raw_text(path) != expected:
213
+ tmp.unlink(missing_ok=True)
214
+ return False
215
+ os.replace(tmp, path)
216
+ return True
217
+
218
+
219
+ def _add_trust(state: dict, wanted: "list[str]") -> "list[str]":
220
+ """Merge `projects[<path>].hasTrustDialogAccepted = true` for every
221
+ wanted path into `state` (in place, repairing a missing or non-object
222
+ `projects`). Returns the paths that were NOT already trusted."""
223
+ projects = state.get("projects")
224
+ if not isinstance(projects, dict):
225
+ projects = {}
226
+ state["projects"] = projects
227
+ added = []
228
+ for p in wanted:
229
+ entry = projects.get(p)
230
+ if not isinstance(entry, dict):
231
+ entry = {}
232
+ projects[p] = entry
233
+ if entry.get(TRUST_KEY) is not True:
234
+ entry[TRUST_KEY] = True
235
+ added.append(p)
236
+ return added
237
+
238
+
239
+ def _untrusted(state: dict, wanted: "list[str]") -> "list[str]":
240
+ projects = state.get("projects")
241
+ if not isinstance(projects, dict):
242
+ return list(wanted)
243
+ return [
244
+ p for p in wanted
245
+ if not isinstance(projects.get(p), dict)
246
+ or projects[p].get(TRUST_KEY) is not True
247
+ ]
248
+
249
+
250
+ def _merge_target(target: Path, wanted: "list[str]") -> bool:
251
+ """Merge `wanted` into ONE claude state file. True when the file was
252
+ written. Load, merge, compare-and-swap, verify -- and start over, up to
253
+ SEED_TRUST_ATTEMPTS times, whenever another writer moved the file
254
+ between the load and the rename (the swap refuses) or right after the
255
+ rename (the verify finds an entry missing). Never raises."""
256
+ wrote = False
257
+ for _ in range(SEED_TRUST_ATTEMPTS):
258
+ try:
259
+ raw, state = _load_state(target)
260
+ except (OSError, ValueError) as exc:
261
+ if wrote:
262
+ # Our write landed; whoever replaced it since left something
263
+ # this daemon will not touch. Nothing more to do here.
264
+ log.debug("trust: %s unreadable after the merge (%s)", target, exc)
265
+ return True
266
+ log.warning(
267
+ "trust: could not read %s (%s) — leaving it untouched; the "
268
+ "directories are NOT pre-trusted there: %s",
269
+ target, exc, ", ".join(wanted),
270
+ )
271
+ return False
272
+ added = _add_trust(state, wanted)
273
+ if not added:
274
+ # Every entry is present: it always was, or the pass before
275
+ # this one landed and survived.
276
+ return wrote
277
+ try:
278
+ swapped = _write_atomic(target, state, raw)
279
+ except OSError as exc:
280
+ log.warning(
281
+ "trust: could not write %s (%s) — the directories are NOT "
282
+ "pre-trusted there: %s", target, exc, ", ".join(added),
283
+ )
284
+ return wrote
285
+ if not swapped:
286
+ log.info(
287
+ "trust: %s changed underneath the merge — merging %s again "
288
+ "on top of the newer file", target, ", ".join(added),
289
+ )
290
+ continue
291
+ wrote = True
292
+ try:
293
+ _, after = _load_state(target)
294
+ except (OSError, ValueError) as exc:
295
+ log.debug("trust: %s unreadable right after the merge (%s)", target, exc)
296
+ return True
297
+ missing = _untrusted(after, wanted)
298
+ if not missing:
299
+ log.debug("trust: %s now pre-trusts %s", target, ", ".join(added))
300
+ return True
301
+ log.info(
302
+ "trust: %s was rewritten right after the merge and lost %s — "
303
+ "merging again", target, ", ".join(missing),
304
+ )
305
+ log.warning(
306
+ "trust: gave up merging into %s after %d attempts — another writer "
307
+ "kept rewriting it, so the directories may NOT be pre-trusted there: %s",
308
+ target, SEED_TRUST_ATTEMPTS, ", ".join(wanted),
309
+ )
310
+ return wrote
311
+
312
+
313
+ def seed_trust(
314
+ paths: "Iterable[Path | str]",
315
+ *,
316
+ home: "Path | str | None" = None,
317
+ config_dir: "Path | str | None" = None,
318
+ ) -> "list[Path]":
319
+ """Merge `projects[<path>].hasTrustDialogAccepted = true` for every path
320
+ into every claude state target. Returns the targets that were WRITTEN
321
+ (a target already carrying every entry is left untouched -- byte for
322
+ byte, no rewrite). Never raises and never removes anything: an
323
+ unparseable target is skipped with a WARNING rather than replaced, a
324
+ failed write is a WARNING too, and a target another writer keeps
325
+ moving under the merge is retried a bounded number of times (see
326
+ `_merge_target` and the module docstring) before that, too, is one
327
+ WARNING.
328
+
329
+ Paths are recorded as absolute strings, which is how Claude Code keys
330
+ them; a relative path is resolved against the cwd, which is never what a
331
+ caller means, so callers pass absolute hubs."""
332
+ wanted = [os.fspath(Path(p)) for p in paths]
333
+ changed: "list[Path]" = []
334
+ if not wanted:
335
+ return changed
336
+ for target in claude_state_targets(home, config_dir):
337
+ if _merge_target(target, wanted):
338
+ changed.append(target)
339
+ return changed
340
+
341
+
342
+ def derived_repos_path(root: "Path | str") -> Path:
343
+ """Where the derived allowlist is written under this workspace root, or
344
+ wherever `ALISSA_DERIVED_REPOS_FILE` points (the same override the
345
+ entrypoint honours, so an operator relocating one relocates both)."""
346
+ override = os.environ.get(ENV_DERIVED_REPOS_FILE, "").strip()
347
+ if override:
348
+ return Path(override)
349
+ return Path(root) / DERIVED_REPOS_FILENAME
350
+
351
+
352
+ def write_derived_repos(root: "Path | str", repos: "Sequence[str]") -> "Path | None":
353
+ """Record the derived allowlist, one `owner/repo` per line, for the next
354
+ boot's entrypoint seeding. Rewritten only when the content differs.
355
+ Returns the path written, or None when nothing changed or the write
356
+ failed (logged; never raised -- the daemon has already trusted the hubs
357
+ itself, so this file only matters to a future boot)."""
358
+ path = derived_repos_path(root)
359
+ body = "".join(f"{r}\n" for r in repos)
360
+ try:
361
+ if path.is_file() and path.read_text(encoding="utf-8") == body:
362
+ return None
363
+ path.parent.mkdir(parents=True, exist_ok=True)
364
+ tmp = path.with_name(path.name + ".tmp")
365
+ tmp.write_text(body, encoding="utf-8")
366
+ os.replace(tmp, path)
367
+ except OSError as exc:
368
+ log.warning(
369
+ "could not record the derived allowlist at %s (%s) — the next "
370
+ "boot's entrypoint seeding will not pre-trust these hubs until a "
371
+ "refresh succeeds: %s", path, exc, ", ".join(repos),
372
+ )
373
+ return None
374
+ return path
375
+
376
+
377
+ def read_derived_repos(root: "Path | str") -> "tuple[str, ...]":
378
+ """The recorded derived allowlist (`owner/repo` per line; blank lines and
379
+ `#` comments ignored), or `()` when absent or unreadable."""
380
+ path = derived_repos_path(root)
381
+ try:
382
+ text = path.read_text(encoding="utf-8")
383
+ except OSError:
384
+ return ()
385
+ out = []
386
+ for line in text.splitlines():
387
+ line = line.strip()
388
+ if line and not line.startswith("#") and "/" in line:
389
+ out.append(line)
390
+ return tuple(out)
391
+
392
+
393
+ def _dialog_line(line: str) -> str:
394
+ """A pane line reduced to the text a gate would render there (see
395
+ _DIALOG_DECORATION), casefolded."""
396
+ text = line.strip(_DIALOG_DECORATION)
397
+ text = _DIALOG_NUMBERING.sub("", text).strip(_DIALOG_DECORATION)
398
+ return text.casefold()
399
+
400
+
401
+ def _is_dialog_chrome(text: str) -> bool:
402
+ return any(text.startswith(chrome) for chrome in FIRST_RUN_DIALOG_CHROME)
403
+
404
+
405
+ def pane_shows_first_run_dialog(pane: str) -> bool:
406
+ """Whether a session's terminal tail is PARKED on one of Claude Code's
407
+ first-run gates: the gate's accept option among the last
408
+ FIRST_RUN_DIALOG_TAIL_LINES non-blank lines, nothing but the gate's own
409
+ chrome below it, and the gate's question at or above it (the notes on
410
+ FIRST_RUN_DIALOG_MARKERS say why each leg is there). An empty capture
411
+ (the CLI could not tail) is never a dialog -- absence of evidence keeps
412
+ the defer -- and neither is a pane that merely MENTIONS a gate: a
413
+ session at work or idle at its prompt has printed something below the
414
+ words, and that something is not the gate's chrome."""
415
+ if not pane:
416
+ return False
417
+ lines = [t for t in (_dialog_line(ln) for ln in pane.splitlines()) if t]
418
+ tail = lines[-FIRST_RUN_DIALOG_TAIL_LINES:]
419
+ for offset in range(len(tail) - 1, -1, -1):
420
+ if any(tail[offset].startswith(o) for o in FIRST_RUN_DIALOG_OPTIONS):
421
+ break
422
+ else:
423
+ return False
424
+ if not all(_is_dialog_chrome(t) for t in tail[offset + 1:]):
425
+ return False
426
+ above = lines[:len(lines) - len(tail) + offset + 1]
427
+ return any(m in t for t in above for m in FIRST_RUN_DIALOG_MARKERS)