scrollback 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 (69) hide show
  1. scrollback/__init__.py +8 -0
  2. scrollback/assets/icon-256.png +0 -0
  3. scrollback/assets/icon.icns +0 -0
  4. scrollback/cli.py +1139 -0
  5. scrollback/clipboard.py +34 -0
  6. scrollback/export.py +293 -0
  7. scrollback/fts.py +307 -0
  8. scrollback/highlight.py +128 -0
  9. scrollback/katexbundle.py +81 -0
  10. scrollback/launcher_install.py +209 -0
  11. scrollback/launchers/scrollback.bat +19 -0
  12. scrollback/launchers/scrollback.command +19 -0
  13. scrollback/launchers/scrollback.desktop +10 -0
  14. scrollback/launchers/scrollback.sh +12 -0
  15. scrollback/mathspan.py +180 -0
  16. scrollback/minimd.py +205 -0
  17. scrollback/models.py +135 -0
  18. scrollback/serialize.py +83 -0
  19. scrollback/serverconfig.py +66 -0
  20. scrollback/sources/__init__.py +6 -0
  21. scrollback/sources/aider.py +244 -0
  22. scrollback/sources/base.py +117 -0
  23. scrollback/sources/claudecode.py +631 -0
  24. scrollback/sources/codex.py +281 -0
  25. scrollback/sources/opencode.py +357 -0
  26. scrollback/sources/registry.py +39 -0
  27. scrollback/store.py +384 -0
  28. scrollback/termrender.py +170 -0
  29. scrollback/web/__init__.py +1 -0
  30. scrollback/web/app.py +359 -0
  31. scrollback/web/static/app.js +1245 -0
  32. scrollback/web/static/apple-touch-icon.png +0 -0
  33. scrollback/web/static/favicon.png +0 -0
  34. scrollback/web/static/favicon.svg +41 -0
  35. scrollback/web/static/index.html +75 -0
  36. scrollback/web/static/style.css +628 -0
  37. scrollback/web/static/vendor/highlight.min.js +1213 -0
  38. scrollback/web/static/vendor/hljs-dark.min.css +10 -0
  39. scrollback/web/static/vendor/hljs-light.min.css +10 -0
  40. scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  41. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  42. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  43. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  44. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  45. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  46. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  47. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  48. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  49. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  50. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  51. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  52. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  53. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  54. scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  55. scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  56. scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  57. scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  58. scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  59. scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  60. scrollback/web/static/vendor/katex/katex.min.css +1 -0
  61. scrollback/web/static/vendor/katex/katex.min.js +1 -0
  62. scrollback/web/static/vendor/marked.min.js +6 -0
  63. scrollback/web/static/vendor/purify.min.js +3 -0
  64. scrollback/webopen.py +96 -0
  65. scrollback-0.1.0.dist-info/METADATA +391 -0
  66. scrollback-0.1.0.dist-info/RECORD +69 -0
  67. scrollback-0.1.0.dist-info/WHEEL +4 -0
  68. scrollback-0.1.0.dist-info/entry_points.txt +4 -0
  69. scrollback-0.1.0.dist-info/licenses/LICENSE +21 -0
scrollback/minimd.py ADDED
@@ -0,0 +1,205 @@
1
+ """A tiny, safe, stdlib-only Markdown-to-HTML renderer.
2
+
3
+ Scope is deliberately small -- the common constructs that appear in AI
4
+ chat transcripts: fenced code blocks, ATX headings, unordered/ordered
5
+ lists, blockquotes, horizontal rules, paragraphs, and inline spans
6
+ (code, bold, italic, links). Everything is HTML-escaped first, so the
7
+ output is safe to drop into an export without an external dependency and
8
+ without risking HTML/script injection from transcript content.
9
+
10
+ This is NOT a CommonMark implementation; it trades completeness for zero
11
+ dependencies and predictable, safe output. The richer browser view uses
12
+ the vendored `marked` + `highlight.js`; this module exists so the static
13
+ HTML *export* renders nicely on its own (e.g. for printing/sharing).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import html as _html
19
+ import re
20
+
21
+ from . import highlight as _highlight
22
+ from . import mathspan as _mathspan
23
+
24
+ _HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
25
+ _UL_ITEM = re.compile(r"^[ \t]*[-*+]\s+(.*)$")
26
+ _OL_ITEM = re.compile(r"^[ \t]*\d+[.)]\s+(.*)$")
27
+ _HR = re.compile(r"^(?:-{3,}|\*{3,}|_{3,})\s*$")
28
+ _FENCE = re.compile(r"^[ \t]*(`{3,}|~{3,})\s*([\w+-]*)\s*$")
29
+ _BLOCKQUOTE = re.compile(r"^>\s?(.*)$")
30
+
31
+
32
+ def render(text: str, *, math: str = "raw") -> str:
33
+ """Render Markdown `text` to a safe HTML fragment.
34
+
35
+ `math` controls how delimited-LaTeX spans (`$...$`, `$$...$$`,
36
+ `\\(...\\)`, `\\[...\\]`) are handled. In every mode the span is first
37
+ shielded from the Markdown pass so `\\`, `_`, `*`, `^` survive intact:
38
+
39
+ - ``"raw"`` -- restore the original delimited source verbatim
40
+ (default; matches the historical behaviour but no longer mangled).
41
+ - ``"latex"`` -- show the LaTeX source verbatim, wrapped so a renderer
42
+ will not typeset it; best for copying into a paper.
43
+ - ``"rendered"`` -- emit a placeholder element the client typesets with
44
+ KaTeX (the static export embeds KaTeX so this works offline).
45
+ """
46
+ masked, tokens = _mathspan.protect(text)
47
+ html = _render_blocks(masked)
48
+ return _mathspan.restore(html, tokens, lambda span: _math_html(span, math))
49
+
50
+
51
+ def _math_html(span: _mathspan.Span, mode: str) -> str:
52
+ """Render one math span to HTML for the static export, per `mode`."""
53
+ if mode == "rendered":
54
+ # Emit the LaTeX in a span the client-side KaTeX pass typesets; the
55
+ # body is escaped so it is inert until KaTeX reads textContent.
56
+ cls = "math-tex math-display" if span.display else "math-tex"
57
+ disp = "true" if span.display else "false"
58
+ return f'<span class="{cls}" data-display="{disp}">{_html.escape(span.body)}</span>'
59
+ if mode == "latex":
60
+ # Verbatim source, never typeset, never mangled.
61
+ return f'<code class="math-src">{_html.escape(span.raw)}</code>'
62
+ # raw: restore the original delimited source verbatim, as inert text.
63
+ return _html.escape(span.raw)
64
+
65
+
66
+ def _render_blocks(text: str) -> str:
67
+ """Render Markdown block structure (math already masked by `render`)."""
68
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
69
+ out: list[str] = []
70
+ i = 0
71
+ n = len(lines)
72
+
73
+ while i < n:
74
+ line = lines[i]
75
+
76
+ # Fenced code block.
77
+ m = _FENCE.match(line)
78
+ if m:
79
+ fence = m.group(1)[0]
80
+ lang = m.group(2).strip()
81
+ j = i + 1
82
+ buf: list[str] = []
83
+ while j < n and not _is_closing_fence(lines[j], fence):
84
+ buf.append(lines[j])
85
+ j += 1
86
+ raw = "\n".join(buf)
87
+ if lang:
88
+ # Highlighter escapes internally and emits only safe spans.
89
+ code = _highlight.highlight(raw, lang)
90
+ cls = f' class="language-{_html.escape(lang)}"'
91
+ else:
92
+ code = _html.escape(raw)
93
+ cls = ""
94
+ out.append(f"<pre><code{cls}>{code}</code></pre>")
95
+ i = j + 1
96
+ continue
97
+
98
+ # Blank line.
99
+ if not line.strip():
100
+ i += 1
101
+ continue
102
+
103
+ # Horizontal rule.
104
+ if _HR.match(line):
105
+ out.append("<hr>")
106
+ i += 1
107
+ continue
108
+
109
+ # Heading.
110
+ m = _HEADING.match(line)
111
+ if m:
112
+ level = len(m.group(1))
113
+ out.append(f"<h{level}>{_inline(m.group(2).strip())}</h{level}>")
114
+ i += 1
115
+ continue
116
+
117
+ # Blockquote (consecutive '>' lines).
118
+ if _BLOCKQUOTE.match(line):
119
+ buf = []
120
+ while i < n and _BLOCKQUOTE.match(lines[i]):
121
+ buf.append(_BLOCKQUOTE.match(lines[i]).group(1))
122
+ i += 1
123
+ inner = _render_blocks("\n".join(buf))
124
+ out.append(f"<blockquote>{inner}</blockquote>")
125
+ continue
126
+
127
+ # Lists (unordered / ordered).
128
+ if _UL_ITEM.match(line) or _OL_ITEM.match(line):
129
+ ordered = bool(_OL_ITEM.match(line))
130
+ pat = _OL_ITEM if ordered else _UL_ITEM
131
+ items: list[str] = []
132
+ while i < n and pat.match(lines[i]):
133
+ items.append(_inline(pat.match(lines[i]).group(1).strip()))
134
+ i += 1
135
+ tag = "ol" if ordered else "ul"
136
+ li = "".join(f"<li>{it}</li>" for it in items)
137
+ out.append(f"<{tag}>{li}</{tag}>")
138
+ continue
139
+
140
+ # Paragraph: gather consecutive non-blank, non-special lines.
141
+ buf = [line]
142
+ i += 1
143
+ while i < n and lines[i].strip() and not _starts_block(lines[i]):
144
+ buf.append(lines[i])
145
+ i += 1
146
+ para = "<br>".join(_inline(b.strip()) for b in buf)
147
+ out.append(f"<p>{para}</p>")
148
+
149
+ return "\n".join(out)
150
+
151
+
152
+ def _is_closing_fence(line: str, fence_char: str) -> bool:
153
+ m = _FENCE.match(line)
154
+ return bool(m and m.group(1)[0] == fence_char)
155
+
156
+
157
+ def _starts_block(line: str) -> bool:
158
+ return bool(
159
+ _HEADING.match(line)
160
+ or _UL_ITEM.match(line)
161
+ or _OL_ITEM.match(line)
162
+ or _HR.match(line)
163
+ or _FENCE.match(line)
164
+ or _BLOCKQUOTE.match(line)
165
+ )
166
+
167
+
168
+ # -- inline spans ----------------------------------------------------------
169
+
170
+ # Process inline code first (and protect its contents), then links, then
171
+ # emphasis. All raw text is escaped before markup substitution.
172
+ _CODE_SPAN = re.compile(r"`([^`]+)`")
173
+ _LINK = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)")
174
+ _BOLD = re.compile(r"\*\*([^*]+)\*\*|__([^_]+)__")
175
+ _ITALIC = re.compile(r"(?<![*\w])\*([^*\n]+)\*(?![*\w])|(?<![_\w])_([^_\n]+)_(?![_\w])")
176
+
177
+
178
+ def _inline(text: str) -> str:
179
+ # Extract code spans first so their contents are not touched by other
180
+ # rules; replace with placeholders, then restore at the end.
181
+ placeholders: list[str] = []
182
+
183
+ def _stash_code(m: re.Match) -> str:
184
+ placeholders.append(f"<code>{_html.escape(m.group(1))}</code>")
185
+ return f"\x00{len(placeholders) - 1}\x00"
186
+
187
+ tmp = _CODE_SPAN.sub(_stash_code, text)
188
+ tmp = _html.escape(tmp)
189
+
190
+ # Links: [label](url) -> escape both parts.
191
+ def _link(m: re.Match) -> str:
192
+ label = m.group(1)
193
+ url = m.group(2)
194
+ return f'<a href="{_html.escape(url, quote=True)}">{label}</a>'
195
+
196
+ # Note: tmp is already escaped, so the bracket/paren chars survive as-is.
197
+ tmp = _LINK.sub(_link, tmp)
198
+ tmp = _BOLD.sub(lambda m: f"<strong>{m.group(1) or m.group(2)}</strong>", tmp)
199
+ tmp = _ITALIC.sub(lambda m: f"<em>{m.group(1) or m.group(2)}</em>", tmp)
200
+
201
+ # Restore code spans.
202
+ def _restore(m: re.Match) -> str:
203
+ return placeholders[int(m.group(1))]
204
+
205
+ return re.sub(r"\x00(\d+)\x00", _restore, tmp)
scrollback/models.py ADDED
@@ -0,0 +1,135 @@
1
+ """Common data model shared by all source adapters.
2
+
3
+ Every adapter normalizes its agent's on-disk representation into these
4
+ immutable dataclasses, so the rest of the program (CLI, search, export,
5
+ web) is agent-agnostic. Keeping these as plain data structures (rather
6
+ than behavior-rich classes) follows the "functions over data structures"
7
+ principle: many functions operate on these few shapes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Literal
15
+
16
+ PartType = Literal[
17
+ "text",
18
+ "reasoning",
19
+ "tool",
20
+ "file",
21
+ "patch",
22
+ "step-start",
23
+ "step-finish",
24
+ "compaction",
25
+ "unknown",
26
+ ]
27
+
28
+ Role = Literal["user", "assistant", "system", "tool"]
29
+
30
+
31
+ def _to_dt(ms_or_iso: int | float | str | None) -> datetime | None:
32
+ """Best-effort conversion of a timestamp to an aware UTC datetime.
33
+
34
+ Accepts epoch milliseconds (opencode) or ISO-8601 strings (Claude Code).
35
+ Returns None when the input is missing or unparseable.
36
+ """
37
+ if ms_or_iso is None:
38
+ return None
39
+ if isinstance(ms_or_iso, (int, float)):
40
+ # opencode stores epoch milliseconds.
41
+ return datetime.fromtimestamp(ms_or_iso / 1000.0, tz=timezone.utc)
42
+ if isinstance(ms_or_iso, str):
43
+ s = ms_or_iso.strip()
44
+ if not s:
45
+ return None
46
+ try:
47
+ # Handle trailing Z.
48
+ if s.endswith("Z"):
49
+ s = s[:-1] + "+00:00"
50
+ dt = datetime.fromisoformat(s)
51
+ except ValueError:
52
+ return None
53
+ # Force tz-awareness: a timezone-less timestamp would otherwise be a
54
+ # naive datetime, which raises TypeError when sorted alongside the
55
+ # aware datetimes used elsewhere. Assume UTC when no offset is given.
56
+ if dt.tzinfo is None:
57
+ dt = dt.replace(tzinfo=timezone.utc)
58
+ return dt
59
+ return None
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Part:
64
+ """A single content block within a message.
65
+
66
+ `text` holds a human-readable rendering of the part regardless of type
67
+ (the message body, the reasoning text, a tool's input/output summary).
68
+ `raw` preserves the adapter's original parsed object for fidelity.
69
+ """
70
+
71
+ id: str
72
+ type: PartType
73
+ text: str = ""
74
+ tool_name: str | None = None
75
+ tool_status: str | None = None
76
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Message:
81
+ """One turn in a conversation, composed of ordered parts."""
82
+
83
+ id: str
84
+ role: Role
85
+ created: datetime | None
86
+ parts: tuple[Part, ...] = ()
87
+ model: str | None = None
88
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
89
+
90
+ @property
91
+ def text(self) -> str:
92
+ """Concatenated text of all textual parts (text + reasoning)."""
93
+ return "\n".join(p.text for p in self.parts if p.text)
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class Session:
98
+ """A whole conversation: metadata plus (optionally) its messages.
99
+
100
+ Listing operations populate metadata only and leave `messages` empty
101
+ for speed; loading a single session populates `messages`.
102
+ """
103
+
104
+ id: str
105
+ source: str # adapter name, e.g. "opencode" / "claudecode"
106
+ title: str
107
+ directory: str | None
108
+ created: datetime | None
109
+ updated: datetime | None
110
+ model: str | None = None
111
+ agent: str | None = None
112
+ parent_id: str | None = None
113
+ message_count: int | None = None
114
+ # Usage accounting (opencode tracks these; None when unknown).
115
+ cost: float | None = None
116
+ tokens_input: int | None = None
117
+ tokens_output: int | None = None
118
+ # Children populated when subagent folding is enabled.
119
+ children: tuple["Session", ...] = ()
120
+ messages: tuple[Message, ...] = ()
121
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
122
+
123
+ @property
124
+ def short_id(self) -> str:
125
+ """A compact id suitable for display and prefix selection."""
126
+ return self.id[:12]
127
+
128
+ @property
129
+ def is_subagent(self) -> bool:
130
+ """True if this session was spawned by another (has a parent)."""
131
+ return bool(self.parent_id)
132
+
133
+
134
+ # Re-export the converter for adapters.
135
+ __all__ = ["Part", "Message", "Session", "PartType", "Role", "_to_dt"]
@@ -0,0 +1,83 @@
1
+ """Plain-dict serializers for the API layer.
2
+
3
+ Kept separate from `export.py` (which renders human-facing documents) and
4
+ from `models.py` (which stays behavior-free). These produce the JSON
5
+ shapes the web frontend consumes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Any
12
+
13
+ from .models import Message, Part, Session
14
+ from .store import SearchHit
15
+
16
+
17
+ def _iso(dt: datetime | None) -> str | None:
18
+ return dt.isoformat() if dt else None
19
+
20
+
21
+ def session_summary(s: Session) -> dict[str, Any]:
22
+ """Lightweight session metadata for list views."""
23
+ return {
24
+ "id": s.id,
25
+ "source": s.source,
26
+ "short_id": s.short_id,
27
+ "title": s.title,
28
+ "directory": s.directory,
29
+ "created": _iso(s.created),
30
+ "updated": _iso(s.updated),
31
+ "model": s.model,
32
+ "agent": s.agent,
33
+ "parent_id": s.parent_id,
34
+ "is_subagent": s.is_subagent,
35
+ "message_count": s.message_count,
36
+ "cost": s.cost,
37
+ "tokens_input": s.tokens_input,
38
+ "tokens_output": s.tokens_output,
39
+ "git_branch": (s.raw or {}).get("git_branch"),
40
+ "children": [session_summary(c) for c in s.children],
41
+ }
42
+
43
+
44
+ def part_dict(p: Part) -> dict[str, Any]:
45
+ return {
46
+ "id": p.id,
47
+ "type": p.type,
48
+ "text": p.text,
49
+ "tool_name": p.tool_name,
50
+ "tool_status": p.tool_status,
51
+ }
52
+
53
+
54
+ def message_dict(m: Message) -> dict[str, Any]:
55
+ return {
56
+ "id": m.id,
57
+ "role": m.role,
58
+ "created": _iso(m.created),
59
+ "model": m.model,
60
+ "parts": [part_dict(p) for p in m.parts],
61
+ }
62
+
63
+
64
+ def session_detail(s: Session) -> dict[str, Any]:
65
+ """Full session including messages/parts."""
66
+ data = session_summary(s)
67
+ data["messages"] = [message_dict(m) for m in s.messages]
68
+ return data
69
+
70
+
71
+ def search_hit(h: SearchHit) -> dict[str, Any]:
72
+ return {
73
+ "source": h.session.source,
74
+ "session_id": h.session.id,
75
+ "short_id": h.session.short_id,
76
+ "title": h.session.title,
77
+ "directory": h.session.directory,
78
+ "message_id": h.message.id,
79
+ "role": h.message.role,
80
+ "part_type": h.part.type,
81
+ "tool_name": h.part.tool_name,
82
+ "snippet": h.snippet,
83
+ }
@@ -0,0 +1,66 @@
1
+ """Single source of truth for the web server's host/port configuration.
2
+
3
+ Resolution order for host and port (highest precedence first):
4
+ 1. an explicit CLI flag (`--host` / `--port`)
5
+ 2. an environment variable (`SCROLLBACK_HOST` / `SCROLLBACK_PORT`)
6
+ 3. the built-in default
7
+
8
+ This lets launchers (which just call `scrollback web --window`) be
9
+ configured without editing any script -- set the env var. It also keeps
10
+ the default in exactly one place rather than scattered magic numbers.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import socket
17
+
18
+ DEFAULT_HOST = "127.0.0.1"
19
+ DEFAULT_PORT = 8765
20
+
21
+ _ENV_HOST = "SCROLLBACK_HOST"
22
+ _ENV_PORT = "SCROLLBACK_PORT"
23
+
24
+
25
+ def default_host() -> str:
26
+ return os.environ.get(_ENV_HOST, DEFAULT_HOST)
27
+
28
+
29
+ def default_port() -> int:
30
+ raw = os.environ.get(_ENV_PORT)
31
+ if raw:
32
+ try:
33
+ return int(raw)
34
+ except ValueError:
35
+ pass
36
+ return DEFAULT_PORT
37
+
38
+
39
+ def is_port_free(host: str, port: int) -> bool:
40
+ """Return True if a TCP server can bind (host, port) right now."""
41
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
42
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
43
+ try:
44
+ sock.bind((host, port))
45
+ return True
46
+ except OSError:
47
+ return False
48
+
49
+
50
+ def resolve_port(host: str, port: int, *, strict: bool = False, tries: int = 20) -> int:
51
+ """Return a bindable port.
52
+
53
+ If `port` is free, use it. Otherwise, unless `strict`, scan upward for
54
+ the next free port (port+1, port+2, ...). Raises OSError if `strict`
55
+ and the port is taken, or if no free port is found within `tries`.
56
+ """
57
+ if is_port_free(host, port):
58
+ return port
59
+ if strict:
60
+ raise OSError(f"port {port} on {host} is already in use")
61
+ for candidate in range(port + 1, port + 1 + tries):
62
+ if is_port_free(host, candidate):
63
+ return candidate
64
+ raise OSError(
65
+ f"no free port found near {port} on {host} (tried {tries} ports)"
66
+ )
@@ -0,0 +1,6 @@
1
+ """Source adapters: one per supported AI agent.
2
+
3
+ Each adapter implements `Source` (see base.py) and is registered in
4
+ `registry.py`. Adding support for a new agent means writing one adapter
5
+ and adding it to the registry -- nothing else in the program changes.
6
+ """