breadcrumbst 0.4.6__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 (60) hide show
  1. breadcrumbs/__init__.py +3 -0
  2. breadcrumbs/__main__.py +163 -0
  3. breadcrumbs/_console_marker.py +350 -0
  4. breadcrumbs/activity.py +326 -0
  5. breadcrumbs/app.py +936 -0
  6. breadcrumbs/assets/branding/NOTICE.md +7 -0
  7. breadcrumbs/assets/branding/logo.png +0 -0
  8. breadcrumbs/assets/branding/terminal.json +1 -0
  9. breadcrumbs/assets/brands/LICENSE-CODICONS.txt +399 -0
  10. breadcrumbs/assets/brands/LICENSE-SIMPLE-ICONS.txt +30 -0
  11. breadcrumbs/assets/brands/NOTICE.md +42 -0
  12. breadcrumbs/assets/brands/claude.svg +1 -0
  13. breadcrumbs/assets/brands/firefoxbrowser.svg +1 -0
  14. breadcrumbs/assets/brands/git.svg +1 -0
  15. breadcrumbs/assets/brands/github.svg +1 -0
  16. breadcrumbs/assets/brands/googlechrome.svg +1 -0
  17. breadcrumbs/assets/brands/masks.json +380 -0
  18. breadcrumbs/assets/brands/neovim.svg +1 -0
  19. breadcrumbs/assets/brands/nodedotjs.svg +1 -0
  20. breadcrumbs/assets/brands/openai.svg +1 -0
  21. breadcrumbs/assets/brands/python.svg +1 -0
  22. breadcrumbs/assets/brands/rasterize.cjs +28 -0
  23. breadcrumbs/assets/brands/spotify.svg +1 -0
  24. breadcrumbs/assets/brands/terminal.svg +1 -0
  25. breadcrumbs/assets/brands/vscode.svg +1 -0
  26. breadcrumbs/assets/brands/window.svg +1 -0
  27. breadcrumbs/assets/brands/youtube.svg +1 -0
  28. breadcrumbs/assets/browser-extension/background.mjs +469 -0
  29. breadcrumbs/assets/browser-extension/connection.css +75 -0
  30. breadcrumbs/assets/browser-extension/connection.js +220 -0
  31. breadcrumbs/assets/browser-extension/core.mjs +82 -0
  32. breadcrumbs/assets/browser-extension/manifest.json +16 -0
  33. breadcrumbs/assets/browser-extension/options.html +63 -0
  34. breadcrumbs/assets/browser-extension/popup.html +63 -0
  35. breadcrumbs/assets/browser-extension/tests/ui.test.mjs +216 -0
  36. breadcrumbs/assets/browser-extension/tests/worker.test.mjs +597 -0
  37. breadcrumbs/branches.py +588 -0
  38. breadcrumbs/branding.py +30 -0
  39. breadcrumbs/brands.py +210 -0
  40. breadcrumbs/bridge.py +365 -0
  41. breadcrumbs/browser_install.py +83 -0
  42. breadcrumbs/config.py +74 -0
  43. breadcrumbs/db.py +477 -0
  44. breadcrumbs/mascot.py +92 -0
  45. breadcrumbs/onboarding.py +170 -0
  46. breadcrumbs/reading.py +30 -0
  47. breadcrumbs/screens.py +365 -0
  48. breadcrumbs/session_names.py +26 -0
  49. breadcrumbs/sound.py +32 -0
  50. breadcrumbs/terminal.py +393 -0
  51. breadcrumbs/timer.py +86 -0
  52. breadcrumbs/update_helper.py +99 -0
  53. breadcrumbs/updater.py +126 -0
  54. breadcrumbs/verses.py +63 -0
  55. breadcrumbs/widgets.py +226 -0
  56. breadcrumbst-0.4.6.dist-info/METADATA +188 -0
  57. breadcrumbst-0.4.6.dist-info/RECORD +60 -0
  58. breadcrumbst-0.4.6.dist-info/WHEEL +4 -0
  59. breadcrumbst-0.4.6.dist-info/entry_points.txt +4 -0
  60. breadcrumbst-0.4.6.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Breadcrumbs terminal focus companion."""
2
+
3
+ __version__ = "0.4.6"
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+
7
+ def build_parser() -> argparse.ArgumentParser:
8
+ from .updater import current_version
9
+
10
+ parser = argparse.ArgumentParser(prog="breadcrumbs", description="Terminal focus companion")
11
+ parser.add_argument("--version", action="version", version=f"breadcrumbs {current_version()}")
12
+ session = parser.add_mutually_exclusive_group()
13
+ session.add_argument("--resume", "--session", metavar="NAME", help="Recover a saved block by name")
14
+ session.add_argument("--name", metavar="NAME", help="Name a new block")
15
+ subparsers = parser.add_subparsers(dest="command")
16
+ subparsers.add_parser("sessions", help="List resumable saved blocks")
17
+ browser = subparsers.add_parser("browser", help="Set up the bundled browser extension")
18
+ browser.add_argument("action", choices=("path", "setup", "repair"), default="setup", nargs="?")
19
+ export = subparsers.add_parser("export", help="Export local activity as JSON")
20
+ export.add_argument("--limit", type=int, default=None)
21
+ export.add_argument("--output", type=Path, default=None)
22
+ export.add_argument("--activity", action="store_true", help="Explicitly include recorded focus context and spans")
23
+ update = subparsers.add_parser("update", help="Update Breadcrumbs from its latest GitHub release")
24
+ mode = update.add_mutually_exclusive_group()
25
+ mode.add_argument("--check", action="store_true", help="Check without installing")
26
+ mode.add_argument("--status", action="store_true", help="Show the last background update result")
27
+ update.add_argument("--yes", action="store_true", help="Install without confirmation")
28
+ return parser
29
+
30
+
31
+ def handle_update(*, check: bool, status: bool, assume_yes: bool) -> None:
32
+ from .updater import (
33
+ UpdateError,
34
+ current_version,
35
+ latest_release,
36
+ launch_update,
37
+ read_update_status,
38
+ update_available,
39
+ )
40
+
41
+ if status:
42
+ result = read_update_status()
43
+ if result is None:
44
+ print("No background update result is available yet.")
45
+ return
46
+ print(f"Update {result.get('status', 'unknown')}: v{result.get('version', '?')}")
47
+ print(f"Log: {result.get('log', 'unavailable')}")
48
+ return
49
+
50
+ installed = current_version()
51
+ try:
52
+ release = latest_release()
53
+ except UpdateError as exc:
54
+ print(f"Update check failed: {exc}")
55
+ return
56
+ print(f"Installed: v{installed} · Latest: {release.tag}")
57
+ if not update_available(release, installed):
58
+ print("Breadcrumbs is already up to date.")
59
+ return
60
+ print(f"Release: {release.page_url}")
61
+ if check:
62
+ return
63
+ if not assume_yes:
64
+ answer = input(f"Install Breadcrumbs {release.tag}? [Y/n] ").strip().lower()
65
+ if answer not in ("", "y", "yes"):
66
+ print("Update cancelled.")
67
+ return
68
+ log_path, _ = launch_update(release)
69
+ print("Update started in the background. Breadcrumbs can now exit safely.")
70
+ print("Wait a moment, then run: breadcrumbs update --status")
71
+ print(f"Detailed log: {log_path}")
72
+
73
+
74
+ def main() -> None:
75
+ parser = build_parser()
76
+ args = parser.parse_args()
77
+ if args.command and (args.resume or args.name):
78
+ parser.error("Use --resume or --name when opening the timer, without a subcommand.")
79
+ if args.command == "sessions":
80
+ from .db import Database
81
+
82
+ database = Database()
83
+ try:
84
+ rows = database.recoverable()
85
+ if not rows:
86
+ print("No saved blocks.")
87
+ for row in rows:
88
+ seconds = int(row["remaining_seconds"])
89
+ print(f"{row['name']} {row['timer_kind']} {seconds // 60}m {seconds % 60}s left")
90
+ print(f" breadcrumbs --resume {row['name']}")
91
+ finally:
92
+ database.close()
93
+ return
94
+ if args.command == "browser":
95
+ if args.action == "repair":
96
+ from .browser_install import main as repair
97
+ raise SystemExit(repair())
98
+ from importlib.resources import files
99
+ print(str(files("breadcrumbs") / "assets" / "browser-extension"))
100
+ if args.action == "setup":
101
+ print("Open chrome://extensions (or edge://extensions), enable Developer mode,")
102
+ print("then Load unpacked and choose the folder above.")
103
+ print("In Breadcrumbs: Config (C) → Browser connection. Copy the address/token")
104
+ print("into the extension, Pair, then Start. No ActivityWatch installation needed.")
105
+ return
106
+ if args.command == "update":
107
+ handle_update(check=args.check, status=args.status, assume_yes=args.yes)
108
+ return
109
+ if args.command == "export":
110
+ from .db import Database
111
+
112
+ database = Database()
113
+ try:
114
+ payload = database.export_json(args.limit, include_activity=args.activity)
115
+ finally:
116
+ database.close()
117
+ if args.output:
118
+ args.output.write_text(payload + "\n", encoding="utf-8")
119
+ print(f"Exported activity to {args.output}")
120
+ else:
121
+ print(payload)
122
+ return
123
+
124
+ from .app import BreadcrumbsApp
125
+ from .db import Database
126
+ from .session_names import normalize_name
127
+
128
+
129
+ for attribute in ("name", "resume"):
130
+ value = getattr(args, attribute)
131
+ if value is not None:
132
+ try:
133
+ setattr(args, attribute, normalize_name(value))
134
+ except ValueError as exc:
135
+ parser.error(str(exc))
136
+ if args.resume or args.name:
137
+ database = Database()
138
+ try:
139
+ if args.resume and not database.recoverable(args.resume):
140
+ parser.error(f"No saved block named '{args.resume}'. Run 'breadcrumbs sessions' to list saves.")
141
+ if args.name and database.name_exists(args.name):
142
+ parser.error("That name is already used. Resume it with --resume or choose another name.")
143
+ finally:
144
+ database.close()
145
+ from .browser_install import refresh_installed_compatibility
146
+ try:
147
+ if refresh_installed_compatibility():
148
+ print("Browser extension files updated. Reload the extension in Chrome/Edge to activate them.")
149
+ except (OSError, ValueError):
150
+ print("Browser extension folder could not be refreshed. Check Config > Browser connection.")
151
+ app = BreadcrumbsApp(resume_name=args.resume, session_name=args.name, show_onboarding=True)
152
+ app.run()
153
+ if app.resume_hint:
154
+ print(f"Saved {app.resume_hint}. Resume with: breadcrumbs --resume {app.resume_hint}")
155
+
156
+
157
+ def legacy_main() -> None:
158
+ print("Tlock is now Breadcrumbs. Use 'breadcrumbs' for future commands.")
159
+ main()
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()
@@ -0,0 +1,350 @@
1
+ """Private hidden helper for a selected coding agent's terminal binding.
2
+
3
+ Invoked as python -m breadcrumbs._console_marker PID BIRTH LEASE PORT. Credentials
4
+ are read from the local pairing file, never from command-line arguments.
5
+ This module does not alter any console on import.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import ctypes
12
+ import json
13
+ import os
14
+ import platform
15
+ import re
16
+ import stat
17
+ import time
18
+ from pathlib import Path
19
+ from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
20
+
21
+ from .bridge import pairing_file
22
+ from .terminal import _kernel, _linux_tty, _proc_stat, discover_agents, process_identity
23
+
24
+
25
+ class MarkerProblem(RuntimeError):
26
+ pass
27
+
28
+
29
+ class _NoRedirect(HTTPRedirectHandler):
30
+ def redirect_request(self, request, response, code, message, headers, new_url):
31
+ return None
32
+
33
+
34
+ class MarkerClient:
35
+ def __init__(self, lease: str, port: int):
36
+ credentials = json.loads(pairing_file().read_text(encoding="utf-8"))
37
+ token = credentials.get("token", "")
38
+ if not re.fullmatch(r"[a-f0-9]{64}", token):
39
+ raise ValueError("Invalid bridge credentials")
40
+ self.lease = lease
41
+ self.base = f"http://127.0.0.1:{port}"
42
+ self.token = token
43
+ self.opener = build_opener(ProxyHandler({}), _NoRedirect())
44
+
45
+ def _request(self, path: str, payload: dict | None = None) -> dict:
46
+ body = json.dumps(payload).encode() if payload is not None else None
47
+ headers = {"Authorization": "Bearer " + self.token, "Accept": "application/json"}
48
+ if body is not None:
49
+ headers["Content-Type"] = "application/json"
50
+ request = Request(self.base + path, data=body, headers=headers)
51
+ with self.opener.open(request, timeout=0.75) as response:
52
+ value = json.loads(response.read(4096))
53
+ if not isinstance(value, dict):
54
+ raise ValueError("Invalid bridge response")
55
+ return value
56
+
57
+ def permit(self) -> dict:
58
+ return self._request("/v1/agent-permit/" + self.lease)
59
+
60
+ def heartbeat(self, console_key: str = "", problem: str = "") -> None:
61
+ self._request("/v1/agent", dict(lease=self.lease, console_key=console_key, problem=problem))
62
+
63
+
64
+ class WindowsConsoleMarker:
65
+ def __init__(self, pid: int, birth: str, lease: str):
66
+ from ctypes import wintypes as w
67
+
68
+ self.pid, self.birth, self.lease = pid, birth, lease
69
+ self.marker = "[breadcrumbs:" + lease + "]"
70
+ self.kernel = _kernel()
71
+ self.attached = False
72
+ self.applied = False
73
+ self.mutex = None
74
+ self.mutex_held = False
75
+ self.original = ""
76
+ self.written = ""
77
+ self.checked_at = 0.0
78
+ self.ambiguous = False
79
+ self.kernel.AttachConsole.argtypes = [w.DWORD]
80
+ self.kernel.AttachConsole.restype = w.BOOL
81
+ self.kernel.FreeConsole.restype = w.BOOL
82
+ self.kernel.GetConsoleWindow.restype = w.HWND
83
+ self.kernel.GetConsoleTitleW.argtypes = [w.LPWSTR, w.DWORD]
84
+ self.kernel.GetConsoleTitleW.restype = w.DWORD
85
+ self.kernel.SetConsoleTitleW.argtypes = [w.LPCWSTR]
86
+ self.kernel.SetConsoleTitleW.restype = w.BOOL
87
+ self.kernel.GetConsoleProcessList.argtypes = [ctypes.POINTER(w.DWORD), w.DWORD]
88
+ self.kernel.GetConsoleProcessList.restype = w.DWORD
89
+ self.kernel.CreateMutexW.argtypes = [ctypes.c_void_p, w.BOOL, w.LPCWSTR]
90
+ self.kernel.CreateMutexW.restype = w.HANDLE
91
+ self.kernel.WaitForSingleObject.argtypes = [w.HANDLE, w.DWORD]
92
+ self.kernel.WaitForSingleObject.restype = w.DWORD
93
+ self.kernel.ReleaseMutex.argtypes = [w.HANDLE]
94
+ try:
95
+ if process_identity(pid) != birth:
96
+ raise MarkerProblem("Selected process identity changed.")
97
+ # Only the helper entry point detaches its own inherited console;
98
+ # the TUI never calls FreeConsole or attaches another console.
99
+ if not self.kernel.AttachConsole(pid):
100
+ raise MarkerProblem(f"This process has no attachable console, or access was denied (error {ctypes.get_last_error()}).")
101
+ self.attached = True
102
+ if process_identity(pid) != birth:
103
+ raise MarkerProblem("Selected process identity changed.")
104
+ handle = self.kernel.GetConsoleWindow()
105
+ if not handle:
106
+ raise MarkerProblem("Console identity is unavailable for this terminal.")
107
+ self.console_key = f"win-console:{int(handle)}"
108
+ self.mutex = self.kernel.CreateMutexW(None, False, f"Local\\BreadcrumbsConsoleMarker-{int(handle)}")
109
+ if not self.mutex or self.kernel.WaitForSingleObject(self.mutex, 0) not in (0, 128):
110
+ raise MarkerProblem("Another selected agent already owns this console marker.")
111
+ self.mutex_held = True
112
+ except BaseException:
113
+ self.close()
114
+ raise
115
+
116
+ def _title(self) -> str:
117
+ buffer = ctypes.create_unicode_buffer(32768)
118
+ ctypes.set_last_error(0)
119
+ count = self.kernel.GetConsoleTitleW(buffer, len(buffer))
120
+ if count == 0 and ctypes.get_last_error():
121
+ raise MarkerProblem("Console title is not readable.")
122
+ return buffer.value
123
+
124
+ def _check_shared_console(self) -> None:
125
+ from ctypes import wintypes as w
126
+
127
+ if time.monotonic() - self.checked_at < 2:
128
+ if self.ambiguous:
129
+ raise MarkerProblem("Multiple coding agents share this console; active agent is ambiguous.")
130
+ return
131
+ self.checked_at = time.monotonic()
132
+ ids = (w.DWORD * 1024)()
133
+ count = self.kernel.GetConsoleProcessList(ids, len(ids))
134
+ if not count or count > len(ids):
135
+ raise MarkerProblem("Console process membership is unavailable.")
136
+ members = set(ids[:count])
137
+ agents = discover_agents(members)
138
+ self.ambiguous = len(agents) > 1
139
+ if self.ambiguous:
140
+ raise MarkerProblem("Multiple coding agents share this console; active agent is ambiguous.")
141
+
142
+ def activate(self) -> None:
143
+ if process_identity(self.pid) != self.birth:
144
+ raise MarkerProblem("Selected process identity changed.")
145
+ self._check_shared_console()
146
+ title = self._title()
147
+ other = [value for value in re.findall(r"\[breadcrumbs:([a-f0-9]{24})\]", title) if value != self.lease]
148
+ if other:
149
+ raise MarkerProblem("This console already contains another agent marker.")
150
+ # Preserve title changes made by the CLI itself while stripping only
151
+ # this helper's own marker on pause or exit.
152
+ base = title.replace(self.marker, "").rstrip()
153
+ if not self.applied or self.marker not in title:
154
+ self.original = base
155
+ self.written = (base[:32000] + " " + self.marker).strip()
156
+ if not self.kernel.SetConsoleTitleW(self.written):
157
+ raise MarkerProblem("This terminal does not accept console title markers.")
158
+ self.applied = True
159
+
160
+ def restore(self) -> None:
161
+ if not self.attached or not self.applied:
162
+ return
163
+ try:
164
+ title = self._title()
165
+ if self.marker in title:
166
+ restored = self.original if title == self.written else title.replace(self.marker, "").rstrip()
167
+ self.kernel.SetConsoleTitleW(restored)
168
+ finally:
169
+ self.applied = False
170
+
171
+ def close(self) -> None:
172
+ try:
173
+ self.restore()
174
+ except (OSError, MarkerProblem):
175
+ pass
176
+ if self.mutex:
177
+ if self.mutex_held:
178
+ self.kernel.ReleaseMutex(self.mutex)
179
+ self.kernel.CloseHandle(self.mutex)
180
+ self.mutex = None
181
+ if self.attached:
182
+ self.kernel.FreeConsole()
183
+ self.attached = False
184
+
185
+
186
+ def _xterm_ancestry(pid: int) -> bool:
187
+ """Only a known xterm ancestry is allowed; tmux/screen need integration."""
188
+ seen = set()
189
+ for _ in range(64):
190
+ if pid < 1 or pid in seen:
191
+ return False
192
+ seen.add(pid)
193
+ try:
194
+ directory = Path(f"/proc/{pid}")
195
+ if directory.stat().st_uid != os.geteuid():
196
+ return False
197
+ name = (directory / "comm").read_text().strip().lower()
198
+ if name.startswith(("tmux", "screen")):
199
+ return False
200
+ if name in ("xterm", "uxterm"):
201
+ return True
202
+ _state, pid, _tty, _start = _proc_stat((directory / "stat").read_text())
203
+ except (OSError, ValueError, IndexError):
204
+ return False
205
+ return False
206
+
207
+
208
+ def _foreground_job(pid: int) -> bool:
209
+ try:
210
+ value = Path(f"/proc/{pid}/stat").read_text()
211
+ fields = value[value.rfind(")") + 1:].split()
212
+ group, foreground = int(fields[2]), int(fields[5])
213
+ return group > 0 and group == foreground
214
+ except (OSError, ValueError, IndexError):
215
+ return False
216
+
217
+
218
+ class LinuxTTYMarker:
219
+ def __init__(self, pid: int, birth: str, lease: str):
220
+ self.pid, self.birth = pid, birth
221
+ self.marker = ("[breadcrumbs:" + lease + "]").encode("ascii")
222
+ self.descriptor = None
223
+ self.applied = False
224
+ self.checked_at = 0.0
225
+ self.ambiguous = False
226
+ if process_identity(pid) != birth:
227
+ raise MarkerProblem("Selected process identity changed.")
228
+ tty = _linux_tty(pid)
229
+ if not tty or not re.fullmatch(r"/dev/(?:pts/\d+|tty\d+)", tty):
230
+ raise MarkerProblem("The selected process has no accessible owned terminal.")
231
+ if not _xterm_ancestry(pid):
232
+ raise MarkerProblem("Safe title restoration is not verified for this Linux terminal; xterm is supported.")
233
+ flags = os.O_WRONLY | os.O_NOCTTY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
234
+ descriptor = os.open(tty, flags)
235
+ metadata = os.fstat(descriptor)
236
+ if not stat.S_ISCHR(metadata.st_mode) or metadata.st_uid != os.geteuid():
237
+ os.close(descriptor)
238
+ raise MarkerProblem("The terminal is not an owned character device.")
239
+ self.descriptor = descriptor
240
+ self.console_key = f"linux-tty:{os.major(metadata.st_rdev)}:{os.minor(metadata.st_rdev)}"
241
+
242
+ def _write(self, sequence: bytes) -> None:
243
+ if self.descriptor is None:
244
+ return
245
+ while sequence:
246
+ count = os.write(self.descriptor, sequence)
247
+ if count <= 0:
248
+ raise MarkerProblem("Terminal title marker could not be written.")
249
+ sequence = sequence[count:]
250
+
251
+ def activate(self) -> None:
252
+ if process_identity(self.pid) != self.birth:
253
+ raise MarkerProblem("Selected process identity changed.")
254
+ if not _foreground_job(self.pid):
255
+ raise MarkerProblem("The selected agent is not the terminal's foreground job.")
256
+ if time.monotonic() - self.checked_at >= 2:
257
+ self.checked_at = time.monotonic()
258
+ tty = _linux_tty(self.pid)
259
+ siblings = [target for target in discover_agents() if tty and target.get("tty") == tty]
260
+ self.ambiguous = len(siblings) > 1
261
+ if self.ambiguous:
262
+ raise MarkerProblem("Multiple coding agents share this terminal; active agent is ambiguous.")
263
+ if not self.applied:
264
+ self._write(b"\x1b[22;2t") # xterm: push window title, without reading terminal input
265
+ self.applied = True
266
+ self._write(b"\x1b]2;" + self.marker + b"\x07")
267
+
268
+ def restore(self) -> None:
269
+ if self.applied:
270
+ try:
271
+ self._write(b"\x1b[23;2t") # pop the saved window title
272
+ finally:
273
+ self.applied = False
274
+
275
+ def close(self) -> None:
276
+ try:
277
+ self.restore()
278
+ except (OSError, MarkerProblem):
279
+ pass
280
+ if self.descriptor is not None:
281
+ os.close(self.descriptor)
282
+ self.descriptor = None
283
+
284
+
285
+ def marker_for(pid: int, birth: str, lease: str):
286
+ if platform.system() == "Windows":
287
+ return WindowsConsoleMarker(pid, birth, lease)
288
+ if platform.system() == "Linux":
289
+ return LinuxTTYMarker(pid, birth, lease)
290
+ raise MarkerProblem("Terminal process markers are unavailable on this platform.")
291
+
292
+
293
+ def run_marker(pid: int, birth: str, lease: str, client, factory=marker_for,
294
+ identity=process_identity, sleep=time.sleep) -> None:
295
+ marker = None
296
+ try:
297
+ while True:
298
+ if identity(pid) != birth:
299
+ client.heartbeat(problem="Selected process ended or its identity changed.")
300
+ break
301
+ permit = client.permit()
302
+ if permit.get("stopping"):
303
+ break
304
+ problem = ""
305
+ if permit.get("active") is True:
306
+ try:
307
+ if marker is None:
308
+ marker = factory(pid, birth, lease)
309
+ marker.activate()
310
+ except (MarkerProblem, OSError) as error:
311
+ if marker is not None:
312
+ marker.restore()
313
+ problem = str(error) if isinstance(error, MarkerProblem) else "Terminal marker is unavailable."
314
+ elif marker is not None:
315
+ marker.restore()
316
+ client.heartbeat(console_key=marker.console_key if marker and marker.applied else "", problem=problem)
317
+ sleep(0.4)
318
+ except (OSError, ValueError):
319
+ # Lost bridge/credentials: immediately restore rather than leave a live tag.
320
+ pass
321
+ finally:
322
+ if marker is not None:
323
+ marker.close()
324
+
325
+
326
+ def main() -> None:
327
+ parser = argparse.ArgumentParser(description="Private Breadcrumbs console marker helper")
328
+ parser.add_argument("pid", type=int)
329
+ parser.add_argument("birth")
330
+ parser.add_argument("lease")
331
+ parser.add_argument("port", type=int)
332
+ args = parser.parse_args()
333
+ if args.pid < 1 or not 1 <= args.port <= 65535:
334
+ return
335
+ if not re.fullmatch(r"[a-z0-9:-]{1,120}", args.birth) or not re.fullmatch(r"[a-f0-9]{24}", args.lease):
336
+ return
337
+ try:
338
+ if platform.system() == "Windows":
339
+ # A Windows venv launcher may give its child a console despite the
340
+ # outer CREATE_NO_WINDOW flag. Detach only this private helper.
341
+ kernel = _kernel()
342
+ kernel.FreeConsole()
343
+ client = MarkerClient(args.lease, args.port)
344
+ run_marker(args.pid, args.birth, args.lease, client)
345
+ except (OSError, ValueError, KeyboardInterrupt):
346
+ pass
347
+
348
+
349
+ if __name__ == "__main__":
350
+ main()