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.
- breadcrumbs/__init__.py +3 -0
- breadcrumbs/__main__.py +163 -0
- breadcrumbs/_console_marker.py +350 -0
- breadcrumbs/activity.py +326 -0
- breadcrumbs/app.py +936 -0
- breadcrumbs/assets/branding/NOTICE.md +7 -0
- breadcrumbs/assets/branding/logo.png +0 -0
- breadcrumbs/assets/branding/terminal.json +1 -0
- breadcrumbs/assets/brands/LICENSE-CODICONS.txt +399 -0
- breadcrumbs/assets/brands/LICENSE-SIMPLE-ICONS.txt +30 -0
- breadcrumbs/assets/brands/NOTICE.md +42 -0
- breadcrumbs/assets/brands/claude.svg +1 -0
- breadcrumbs/assets/brands/firefoxbrowser.svg +1 -0
- breadcrumbs/assets/brands/git.svg +1 -0
- breadcrumbs/assets/brands/github.svg +1 -0
- breadcrumbs/assets/brands/googlechrome.svg +1 -0
- breadcrumbs/assets/brands/masks.json +380 -0
- breadcrumbs/assets/brands/neovim.svg +1 -0
- breadcrumbs/assets/brands/nodedotjs.svg +1 -0
- breadcrumbs/assets/brands/openai.svg +1 -0
- breadcrumbs/assets/brands/python.svg +1 -0
- breadcrumbs/assets/brands/rasterize.cjs +28 -0
- breadcrumbs/assets/brands/spotify.svg +1 -0
- breadcrumbs/assets/brands/terminal.svg +1 -0
- breadcrumbs/assets/brands/vscode.svg +1 -0
- breadcrumbs/assets/brands/window.svg +1 -0
- breadcrumbs/assets/brands/youtube.svg +1 -0
- breadcrumbs/assets/browser-extension/background.mjs +469 -0
- breadcrumbs/assets/browser-extension/connection.css +75 -0
- breadcrumbs/assets/browser-extension/connection.js +220 -0
- breadcrumbs/assets/browser-extension/core.mjs +82 -0
- breadcrumbs/assets/browser-extension/manifest.json +16 -0
- breadcrumbs/assets/browser-extension/options.html +63 -0
- breadcrumbs/assets/browser-extension/popup.html +63 -0
- breadcrumbs/assets/browser-extension/tests/ui.test.mjs +216 -0
- breadcrumbs/assets/browser-extension/tests/worker.test.mjs +597 -0
- breadcrumbs/branches.py +588 -0
- breadcrumbs/branding.py +30 -0
- breadcrumbs/brands.py +210 -0
- breadcrumbs/bridge.py +365 -0
- breadcrumbs/browser_install.py +83 -0
- breadcrumbs/config.py +74 -0
- breadcrumbs/db.py +477 -0
- breadcrumbs/mascot.py +92 -0
- breadcrumbs/onboarding.py +170 -0
- breadcrumbs/reading.py +30 -0
- breadcrumbs/screens.py +365 -0
- breadcrumbs/session_names.py +26 -0
- breadcrumbs/sound.py +32 -0
- breadcrumbs/terminal.py +393 -0
- breadcrumbs/timer.py +86 -0
- breadcrumbs/update_helper.py +99 -0
- breadcrumbs/updater.py +126 -0
- breadcrumbs/verses.py +63 -0
- breadcrumbs/widgets.py +226 -0
- breadcrumbst-0.4.6.dist-info/METADATA +188 -0
- breadcrumbst-0.4.6.dist-info/RECORD +60 -0
- breadcrumbst-0.4.6.dist-info/WHEEL +4 -0
- breadcrumbst-0.4.6.dist-info/entry_points.txt +4 -0
- breadcrumbst-0.4.6.dist-info/licenses/LICENSE +21 -0
breadcrumbs/__init__.py
ADDED
breadcrumbs/__main__.py
ADDED
|
@@ -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()
|