sennet 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 (96) hide show
  1. sennet/__init__.py +3 -0
  2. sennet/__main__.py +3 -0
  3. sennet/access.py +121 -0
  4. sennet/agents/__init__.py +0 -0
  5. sennet/agents/base.py +315 -0
  6. sennet/agents/callbacks.py +99 -0
  7. sennet/agents/claude.py +531 -0
  8. sennet/agents/codex.py +518 -0
  9. sennet/agents/registry.py +44 -0
  10. sennet/aio.py +21 -0
  11. sennet/approvals.py +191 -0
  12. sennet/cli.py +442 -0
  13. sennet/config.py +162 -0
  14. sennet/db.py +457 -0
  15. sennet/events.py +93 -0
  16. sennet/install.py +166 -0
  17. sennet/projects.py +116 -0
  18. sennet/prompts.py +199 -0
  19. sennet/search.py +96 -0
  20. sennet/server.py +567 -0
  21. sennet/service.py +322 -0
  22. sennet/setup.py +568 -0
  23. sennet/speech/__init__.py +9 -0
  24. sennet/speech/base.py +84 -0
  25. sennet/speech/fakes.py +68 -0
  26. sennet/speech/hardware.py +86 -0
  27. sennet/speech/kokoro_vocab.json +116 -0
  28. sennet/speech/models.py +207 -0
  29. sennet/speech/preview.py +37 -0
  30. sennet/speech/runtime.py +68 -0
  31. sennet/speech/service.py +139 -0
  32. sennet/speech/stt.py +106 -0
  33. sennet/speech/tts.py +108 -0
  34. sennet/speech/turn.py +72 -0
  35. sennet/speech/vad.py +38 -0
  36. sennet/speech/voices.py +46 -0
  37. sennet/speech/worker.py +57 -0
  38. sennet/tailscale.py +82 -0
  39. sennet/tasks.py +584 -0
  40. sennet/usage.py +141 -0
  41. sennet/voice/__init__.py +5 -0
  42. sennet/voice/conversation.py +302 -0
  43. sennet/voice/endpoint.py +115 -0
  44. sennet/voice/frames.py +121 -0
  45. sennet/voice/hub.py +356 -0
  46. sennet/voice/opus.py +149 -0
  47. sennet/voice/recognition.py +141 -0
  48. sennet/voice/session.py +623 -0
  49. sennet/voice/speakable.py +243 -0
  50. sennet/voice/summary.py +35 -0
  51. sennet/web/apple-touch-icon.png +0 -0
  52. sennet/web/assets/approval-chime-BVC0aYXp.mp3 +0 -0
  53. sennet/web/assets/capture.worklet-B_5sKcQZ.js +1 -0
  54. sennet/web/assets/capture.worklet-B_5sKcQZ.js.gz +0 -0
  55. sennet/web/assets/index-C3BNiJz0.js +43 -0
  56. sennet/web/assets/index-C3BNiJz0.js.gz +0 -0
  57. sennet/web/assets/index-sDIJj-RH.css +2 -0
  58. sennet/web/assets/index-sDIJj-RH.css.gz +0 -0
  59. sennet/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  60. sennet/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  61. sennet/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  62. sennet/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  63. sennet/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  64. sennet/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  65. sennet/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  66. sennet/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  67. sennet/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  68. sennet/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  69. sennet/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  70. sennet/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  71. sennet/web/assets/newsreader-latin-ext-opsz-italic-CSPGnFdO.woff2 +0 -0
  72. sennet/web/assets/newsreader-latin-ext-opsz-normal-BQn1nviT.woff2 +0 -0
  73. sennet/web/assets/newsreader-latin-opsz-italic-kJKFiXvB.woff2 +0 -0
  74. sennet/web/assets/newsreader-latin-opsz-normal-s-izfB6B.woff2 +0 -0
  75. sennet/web/assets/newsreader-vietnamese-opsz-italic-DTr-ymY4.woff2 +0 -0
  76. sennet/web/assets/newsreader-vietnamese-opsz-normal-BjhtXyW2.woff2 +0 -0
  77. sennet/web/assets/playback.worklet-bBWNKQyb.js +1 -0
  78. sennet/web/assets/playback.worklet-bBWNKQyb.js.gz +0 -0
  79. sennet/web/assets/wake-chime-DUVL_Hl-.mp3 +0 -0
  80. sennet/web/assets/wakeword.worker-CbCH--VP.js +12 -0
  81. sennet/web/assets/wakeword.worker-CbCH--VP.js.gz +0 -0
  82. sennet/web/favicon.svg +1 -0
  83. sennet/web/icon-192.png +0 -0
  84. sennet/web/icon-512.png +0 -0
  85. sennet/web/index.html +20 -0
  86. sennet/web/licenses.txt +2787 -0
  87. sennet/web/licenses.txt.gz +0 -0
  88. sennet/web/manifest.webmanifest +13 -0
  89. sennet/web/notices.txt +194 -0
  90. sennet/web/notices.txt.gz +0 -0
  91. sennet-0.1.0.dist-info/METADATA +115 -0
  92. sennet-0.1.0.dist-info/RECORD +96 -0
  93. sennet-0.1.0.dist-info/WHEEL +4 -0
  94. sennet-0.1.0.dist-info/entry_points.txt +2 -0
  95. sennet-0.1.0.dist-info/licenses/LICENSE +202 -0
  96. sennet-0.1.0.dist-info/licenses/NOTICES +194 -0
sennet/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sennet: talk to your coding agents."""
2
+
3
+ __version__ = "0.1.0"
sennet/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
sennet/access.py ADDED
@@ -0,0 +1,121 @@
1
+ """Paired devices: who may use Sennet (HLD §8, docs/m5-access.md).
2
+
3
+ There are no accounts. A device pairs once by redeeming a pairing code, and gets a
4
+ token of its own, which it keeps in a cookie until the device is revoked. A pairing
5
+ code comes in two forms, either of which redeems it, once: a long code carried in a
6
+ link (`<url>/pair#<code>`), and a short one to type on a phone that can't scan the
7
+ link's QR code. Codes and tokens are stored only as SHA-256 hashes.
8
+
9
+ `sennet pair` makes codes straight in the database, so anyone who can run it (the
10
+ user) can always pair a device, with or without the daemon running.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import re
16
+ import secrets
17
+ import time
18
+ from collections import deque
19
+ from dataclasses import dataclass
20
+
21
+ from .db import Database
22
+
23
+ COOKIE = "sennet_device"
24
+ COOKIE_MAX_AGE_S = 365 * 24 * 60 * 60
25
+ TERMINAL = "the terminal"
26
+ TERMINAL_TTL_S = 15 * 60 # a code from `sennet pair`
27
+ DEVICE_TTL_S = 5 * 60 # a code another device shows
28
+ # Typed codes leave out characters that are easy to confuse: 0 and O, 1, I and L.
29
+ ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
30
+ TYPED_LENGTH = 8 # about 40 bits; with expiry and the throttle, enough to type
31
+ SEEN_EVERY_S = 60 # how often a device's last-seen time is written
32
+ NAME_LENGTH = 60
33
+
34
+
35
+ @dataclass
36
+ class Pairing:
37
+ link_code: str
38
+ typed_code: str # shown as XXXX-XXXX
39
+ expires_at: float
40
+
41
+ def link(self, base_url: str) -> str:
42
+ return f"{base_url.rstrip('/')}/pair#{self.link_code}"
43
+
44
+
45
+ def _hash(secret: str) -> str:
46
+ return hashlib.sha256(secret.encode()).hexdigest()
47
+
48
+
49
+ def _typed(code: str) -> str:
50
+ """A typed code as stored: capitals, without the dash or spaces people add."""
51
+ return re.sub(r"[^0-9A-Z]", "", code.upper())
52
+
53
+
54
+ def new_pairing(db: Database, created_by: str, ttl_s: float) -> Pairing:
55
+ typed = "".join(secrets.choice(ALPHABET) for _ in range(TYPED_LENGTH))
56
+ pairing = Pairing(secrets.token_urlsafe(16), f"{typed[:4]}-{typed[4:]}", time.time() + ttl_s)
57
+ db.add_pairing(_hash(pairing.link_code), _hash(typed), created_by, pairing.expires_at)
58
+ return pairing
59
+
60
+
61
+ def redeem(db: Database, code: str, user_agent: str) -> tuple[dict, str] | None:
62
+ """Pair a device with a code in either form: the new device and its token, or None for a code that
63
+ is wrong, used or expired."""
64
+ code = code.strip()
65
+ if not code or len(code) > 64:
66
+ return None
67
+ pairing = db.take_pairing(_hash(code)) or db.take_pairing(_hash(_typed(code)))
68
+ if pairing is None:
69
+ return None
70
+ token = secrets.token_urlsafe(32)
71
+ device = db.add_device(device_name(user_agent), _hash(token), pairing["created_by"])
72
+ return device, token
73
+
74
+
75
+ def device_for(db: Database, token: str | None) -> dict | None:
76
+ """The device a cookie's token belongs to, noting that it was seen."""
77
+ if not token:
78
+ return None
79
+ device = db.device_by_token(_hash(token))
80
+ if device and time.time() - device["last_seen_at"] > SEEN_EVERY_S:
81
+ device["last_seen_at"] = time.time()
82
+ db.update_device(device["id"], last_seen_at=device["last_seen_at"])
83
+ return device
84
+
85
+
86
+ def rename(db: Database, device_id: int, name: str) -> dict | None:
87
+ name = " ".join(name.split())[:NAME_LENGTH]
88
+ if not name:
89
+ raise ValueError("a device needs a name")
90
+ db.update_device(device_id, name=name)
91
+ return db.device(device_id)
92
+
93
+
94
+ def device_name(user_agent: str) -> str:
95
+ """A name a person recognises, such as "Firefox on Linux", from a browser's user agent."""
96
+ browser = next((name for pattern, name in (
97
+ (r"Edg/", "Edge"), (r"OPR/|Opera", "Opera"), (r"Firefox/|FxiOS/", "Firefox"),
98
+ (r"Chrome/|CriOS/", "Chrome"), (r"Safari/", "Safari")) if re.search(pattern, user_agent)), "Browser")
99
+ system = next((name for pattern, name in (
100
+ (r"iPhone", "iPhone"), (r"iPad", "iPad"), (r"Android", "Android"), (r"Mac OS X|Macintosh", "macOS"),
101
+ (r"Windows", "Windows"), (r"CrOS", "ChromeOS"), (r"Linux", "Linux")) if re.search(pattern, user_agent)), None)
102
+ return f"{browser} on {system}" if system else browser
103
+
104
+
105
+ class Throttle:
106
+ """Wrong pairing codes, counted across every client: past `limit` a minute, redeeming waits."""
107
+
108
+ def __init__(self, limit: int = 10, window_s: float = 60):
109
+ self.limit = limit
110
+ self.window_s = window_s
111
+ self.failures: deque[float] = deque()
112
+
113
+ def wait(self) -> float:
114
+ """Seconds until a code may be tried again; 0 when it may be now."""
115
+ now = time.monotonic()
116
+ while self.failures and now - self.failures[0] > self.window_s:
117
+ self.failures.popleft()
118
+ return self.failures[0] + self.window_s - now if len(self.failures) >= self.limit else 0
119
+
120
+ def failed(self):
121
+ self.failures.append(time.monotonic())
File without changes
sennet/agents/base.py ADDED
@@ -0,0 +1,315 @@
1
+ """Provider-neutral agent contract.
2
+
3
+ Adapters translate a CLI's protocol into these events and requests. Nothing here
4
+ knows about Codex, Claude Code or Gemini specifics, and nothing outside an adapter
5
+ sees a provider's own words: statuses, kinds and modes are the vocabularies below,
6
+ and `check_event` holds every adapter to them.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Awaitable, Callable, Literal, Protocol, get_args
13
+
14
+ if TYPE_CHECKING:
15
+ from .callbacks import CallbackServer
16
+
17
+ Decision = Literal["approve", "approve_always", "decline", "cancel"]
18
+
19
+ # How a turn ended. An adapter maps its provider's words to these.
20
+ TurnStatus = Literal["completed", "cancelled", "failed"]
21
+ # A command or file change: running while in progress, then one of the others.
22
+ ItemStatus = Literal["running", "completed", "failed", "declined"]
23
+ ChangeKind = Literal["add", "delete", "update", "move"]
24
+ StepStatus = Literal["pending", "active", "done"]
25
+ # Who started a turn: the user's message, or the agent itself (see `background_turns`).
26
+ TurnStarter = Literal["user", "agent"]
27
+
28
+ # What the agent may do without asking (the approval policy, §4.4 of the HLD):
29
+ # ask: asks before anything outside the agent's own trusted set
30
+ # auto: works freely within its access level, asks only to go beyond it
31
+ # never: never asks; anything beyond its access level fails
32
+ # classifier: the agent's own safety classifier allows or blocks each action, nothing asked
33
+ # up front (Claude Code's auto mode); not a sandbox, so it can reach past `workspace`
34
+ ApprovalMode = Literal["ask", "auto", "never", "classifier"]
35
+ # What the agent may touch: read-only, edit the project directory, or anything.
36
+ Access = Literal["read-only", "workspace", "full"]
37
+ APPROVAL_MODES: tuple[str, ...] = get_args(ApprovalMode)
38
+ ACCESS_LEVELS: tuple[str, ...] = get_args(Access)
39
+
40
+ # Given to every agent as instructions alongside its own. Its messages are read aloud as they
41
+ # arrive, so it states its plan up front and reports briefly between steps; a long
42
+ # message starts with a version written to be heard; the voice hub speaks it as soon as it is
43
+ # streamed, and the timeline hides it (voice-protocol §9). `{ask_tool}` names the agent's
44
+ # own tool for asking the user, when it has one, because naming it is what stops its use.
45
+ SPOKEN_INSTRUCTIONS = """\
46
+ The user may hear your messages read aloud by a voice interface instead of reading them.
47
+
48
+ While you work, keep the user in the conversation:
49
+ - Before your first tool call, write one or two sentences saying what you understood them to \
50
+ want and how you'll go about it. State the plan, not the answer. Skip this when you can \
51
+ answer straight away without tools.
52
+ - Between tool calls, when you move on to a new step or find something that changes the \
53
+ plan, write a single short sentence, about ten words, saying what you're doing now or what \
54
+ you found, such as "The test fails on the date parsing, so I'm looking at that next." Never \
55
+ more than one sentence: each update is spoken before the next can be, so a long one falls \
56
+ behind your work. Say it in plain words, without commands, code or paths, and don't repeat \
57
+ what you've already said. Skip it for quick steps that follow on from the last one.
58
+
59
+ When a message to the user is longer than about three sentences, or reports the outcome of \
60
+ your work, start it with a version written to be heard, inside <spoken></spoken> tags, then \
61
+ write the message as you normally would. The spoken version:
62
+ - comes first, before anything else in the message;
63
+ - talks to the user directly, as "you", in plain sentences, most important first: what was \
64
+ done or found, or what went wrong, then anything they need to know or decide;
65
+ - keeps every point that matters, with numbers and names exactly as in the full message, and \
66
+ adds nothing that isn't in it;
67
+ - never repeats a result your updates during the work already said, since the user has just \
68
+ heard it. If the updates covered the results, the spoken version only says how it ended and \
69
+ anything new, such as "Done: both results are in the report file." The written message below \
70
+ it still has everything;
71
+ - has no markdown, lists, code, URLs or file paths (say a file's name, not its path), and \
72
+ writes symbols as words: "24 dollars", not "$24";
73
+ - is usually one to five sentences: shorter than the message, but not cut to a headline;
74
+ - ends with your question, if the message asks the user something.
75
+
76
+ Short messages need no spoken version. Use the tags only in your messages to the user, \
77
+ never in files, commands, commit messages or other tool input.
78
+
79
+ Don't ask the user questions while you are still working, and don't use a tool to ask them\
80
+ {ask_tool}: they can't answer until your turn ends. Work out what you can, then put any \
81
+ questions at the end of your final message, where its spoken version ends with them too."""
82
+
83
+
84
+ def spoken_instructions(ask_tool: str | None = None) -> str:
85
+ """SPOKEN_INSTRUCTIONS for an agent whose tool for asking the user is `ask_tool`."""
86
+ return SPOKEN_INSTRUCTIONS.format(ask_tool=f" (such as {ask_tool})" if ask_tool else "")
87
+
88
+
89
+ @dataclass
90
+ class AgentEvent:
91
+ """A normalized agent event. `data` holds the kind's fields (see EVENT_FIELDS).
92
+
93
+ A turn may send several `message`s (commentary, then the answer); the last
94
+ `message` before `turn_completed` is the turn's answer, and is what gets spoken.
95
+
96
+ An agent with `background_turns` may keep work running after its turn ends
97
+ (`background` lists it) and start a turn of its own when that work is done:
98
+ `turn_started` with `by: "agent"`, then the turn's events and `turn_completed`.
99
+ """
100
+
101
+ kind: str
102
+ data: dict = field(default_factory=dict)
103
+
104
+
105
+ # Required fields per event kind, and the vocabulary a field must use, if any.
106
+ EVENT_FIELDS: dict[str, dict[str, tuple | None]] = {
107
+ "message_delta": {"item_id": None, "delta": None},
108
+ "message": {"item_id": None, "text": None},
109
+ "command_started": {"item_id": None, "command": None},
110
+ "command_finished": {"item_id": None, "command": None, "status": get_args(ItemStatus),
111
+ "exit_code": None, "output_tail": None},
112
+ "file_change": {"item_id": None, "status": get_args(ItemStatus), "changes": None},
113
+ "plan": {"steps": None},
114
+ "error": {"message": None},
115
+ "turn_completed": {"status": get_args(TurnStatus), "error": None},
116
+ "turn_started": {"by": get_args(TurnStarter)},
117
+ # The work still running after the turn, each {"id", "description"}; empty once it is done.
118
+ "background": {"tasks": None},
119
+ # Tokens the turn used, sent before its `turn_completed`: all input (cached included), the
120
+ # part of it read from cache, and output (reasoning included).
121
+ "usage": {"input": None, "cached": None, "output": None},
122
+ # The account's usage limits as the agent last reported them, each window a LimitWindow's
123
+ # fields. They belong to the account, not the task; a report may hold only some windows.
124
+ "limits": {"windows": None},
125
+ }
126
+ # Fields an adapter may add when its provider reports them. Anything else is not the contract's.
127
+ OPTIONAL_FIELDS: dict[str, tuple[str, ...]] = {
128
+ "command_started": ("cwd",), # the directory the command runs in
129
+ "command_finished": ("duration_ms",),
130
+ "error": ("will_retry",), # the agent retries on its own, so the turn goes on
131
+ # The turn failed at a usage limit: {"window": its title, "resets_at"}, either None when unknown.
132
+ "turn_completed": ("limit",),
133
+ }
134
+ LIMIT_WINDOW_FIELDS = {"id", "title", "used", "resets_at"}
135
+
136
+
137
+ def check_event(event: AgentEvent) -> list[str]:
138
+ """What is wrong with an event, by the contract; empty when it conforms."""
139
+ fields = EVENT_FIELDS.get(event.kind)
140
+ if fields is None:
141
+ return [f"unknown event kind {event.kind!r}"]
142
+ problems = []
143
+ for name, allowed in fields.items():
144
+ if name not in event.data:
145
+ problems.append(f"{event.kind} has no {name}")
146
+ elif allowed is not None and event.data[name] not in allowed:
147
+ problems.append(f"{event.kind}.{name} is {event.data[name]!r}, not one of {allowed}")
148
+ for name in event.data.keys() - fields.keys() - set(OPTIONAL_FIELDS.get(event.kind, ())):
149
+ problems.append(f"{event.kind} has a field the contract doesn't define: {name}")
150
+ if event.kind == "file_change":
151
+ for change in event.data.get("changes") or []:
152
+ if change.get("kind") not in get_args(ChangeKind):
153
+ problems.append(f"file_change kind {change.get('kind')!r} is not one of {get_args(ChangeKind)}")
154
+ if event.kind == "background":
155
+ for task in event.data.get("tasks") or []:
156
+ if set(task) != {"id", "description"}:
157
+ problems.append(f"background task {task!r} isn't just an id and a description")
158
+ if event.kind == "limits":
159
+ for window in event.data.get("windows") or []:
160
+ if set(window) != LIMIT_WINDOW_FIELDS:
161
+ problems.append(f"limit window {window!r} doesn't have just {sorted(LIMIT_WINDOW_FIELDS)}")
162
+ if event.kind == "turn_completed" and (limit := event.data.get("limit")) is not None:
163
+ if set(limit) != {"window", "resets_at"}:
164
+ problems.append(f"turn_completed limit {limit!r} isn't just a window and resets_at")
165
+ if event.kind == "plan":
166
+ for step in event.data.get("steps") or []:
167
+ if step.get("status") not in get_args(StepStatus):
168
+ problems.append(f"plan step status {step.get('status')!r} is not one of {get_args(StepStatus)}")
169
+ return problems
170
+
171
+
172
+ ApprovalKind = Literal["command", "edit", "tool", "access"]
173
+
174
+
175
+ @dataclass
176
+ class FileChange:
177
+ path: str
178
+ kind: ChangeKind
179
+ diff: str = "" # unified diff, if the agent shows one
180
+
181
+
182
+ @dataclass
183
+ class ApprovalRequest:
184
+ """Something the agent wants to do that needs the user's say.
185
+
186
+ command: run `command`. edit: change the files in `changes` (or `paths`, when the
187
+ agent doesn't show the changes). tool: use any other tool, named in `tool`.
188
+ access: widen the agent's access, such as network or paths outside the project.
189
+
190
+ The engine writes the one-line summary from these fields; adapters don't.
191
+ """
192
+
193
+ kind: ApprovalKind
194
+ command: str | None = None # as the user would type it, without shell wrappers
195
+ read_only: bool | None = None # the adapter's judgement that the command only reads
196
+ paths: list[str] = field(default_factory=list)
197
+ changes: list[FileChange] = field(default_factory=list)
198
+ tool: str | None = None # e.g. WebFetch, an MCP tool
199
+ tool_input: str | None = None # short and human-readable, e.g. the URL
200
+ reason: str | None = None # the agent's explanation, if any
201
+ network: bool = False
202
+ title: str | None = None # the agent's own one-line description, if it gives one
203
+ extra: dict = field(default_factory=dict) # provider-only detail: shown raw, never interpreted
204
+
205
+
206
+ @dataclass
207
+ class TurnPolicy:
208
+ """How much the agent may do without asking. Each adapter maps it to its CLI's own modes."""
209
+
210
+ mode: ApprovalMode
211
+ access: Access
212
+ # Load the project's own agent configuration (hooks, MCP servers), which can run code unasked.
213
+ project_config: bool = False
214
+ # The model and reasoning effort, by the ids `Agent.models` lists; None for the agent's own choice.
215
+ model: str | None = None
216
+ effort: str | None = None
217
+
218
+
219
+ @dataclass(frozen=True)
220
+ class ModelOption:
221
+ """A model an agent can run, as its CLI names it."""
222
+
223
+ id: str # what the CLI takes, e.g. "opus" or "gpt-6.1-sol"
224
+ title: str
225
+ efforts: tuple[str, ...] = () # reasoning effort levels it takes, lowest first
226
+ default_effort: str | None = None
227
+ default: bool = False # the agent's own default model
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class Capabilities:
232
+ """What an agent really supports. Unsupported behaviour is shown, not faked (HLD §4.1).
233
+
234
+ Nothing has a default: each adapter states every capability, so none is claimed by omission.
235
+ """
236
+
237
+ interrupt: bool # can stop a turn and keep the session
238
+ resume: bool # can continue a task's conversation in a new process
239
+ live_approvals: bool # asks for approval mid-turn, so `ask` mode works
240
+ approval_modes: tuple[str, ...] # of APPROVAL_MODES
241
+ access_levels: tuple[str, ...] # of ACCESS_LEVELS
242
+ background_turns: bool # keeps working after its turn ends, and may start a turn itself
243
+ project_config: bool # leaves a project's own configuration out unless the project opts in
244
+ live_model_change: bool # changes model and effort between turns; else its process restarts, resuming
245
+
246
+
247
+ EventSink = Callable[[AgentEvent], Awaitable[None]]
248
+ ApprovalHandler = Callable[[ApprovalRequest], Awaitable[Decision]]
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class LimitWindow:
253
+ """One of the account's usage limits, such as the five-hour one, as the agent reports it."""
254
+
255
+ id: str # stable across reports: "session" (five hours), "weekly", ...
256
+ title: str # as the user knows it, e.g. "5-hour"; read out as "Claude's 5-hour limit"
257
+ used: int # percent of it used, 0 to 100
258
+ resets_at: float | None # Unix time
259
+
260
+
261
+ @dataclass
262
+ class AgentStatus:
263
+ installed: bool
264
+ logged_in: bool | None
265
+ detail: str
266
+
267
+
268
+ class AgentSession(Protocol):
269
+ # Opaque to everything but the adapter: a thread, session or conversation id that
270
+ # `Agent.open` takes back to continue the same conversation.
271
+ resume_token: str
272
+
273
+ async def send(self, text: str) -> None:
274
+ """Start a turn. Events and approval requests arrive through the callbacks
275
+ until a `turn_completed` event."""
276
+
277
+ async def interrupt(self) -> None:
278
+ """Ask the agent to stop the current turn. Only called when `capabilities.interrupt`."""
279
+
280
+ async def set_model(self, model: str | None, effort: str | None) -> None:
281
+ """Use this model and effort from the next turn (None: the agent's default). Only called
282
+ when `capabilities.live_model_change`; otherwise the session is reopened with them."""
283
+
284
+ async def close(self) -> None:
285
+ """Stop the agent process. With `capabilities.resume`, the conversation continues
286
+ later from `resume_token`."""
287
+
288
+ @property
289
+ def alive(self) -> bool: ...
290
+
291
+
292
+ class Agent(Protocol):
293
+ """An adapter, built from the command that starts its CLI (`agents.commands` in the config) and
294
+ the daemon's MCP endpoint (agents.callbacks), for a CLI that calls back into Sennet."""
295
+
296
+ name: str
297
+ title: str # as the user knows it, e.g. "Claude Code"
298
+ capabilities: Capabilities
299
+ default_command: list[str]
300
+
301
+ def __init__(self, command: list[str], callbacks: CallbackServer | None = None): ...
302
+
303
+ async def status(self) -> AgentStatus: ...
304
+
305
+ async def models(self) -> list[ModelOption]:
306
+ """The models the user may pick from, the agent's default marked if it is known."""
307
+
308
+ async def limits(self) -> list[LimitWindow] | None:
309
+ """The account's usage limits, asked for without using any; None when the agent can't say
310
+ (not logged in, or logged in without a plan that has them)."""
311
+
312
+ async def open(self, cwd: Path, resume_token: str | None, policy: TurnPolicy,
313
+ on_event: EventSink, on_approval: ApprovalHandler) -> AgentSession:
314
+ """Start a session. A resumed conversation may keep the model it last used, so with
315
+ `policy.model` None the adapter asks for the agent's default explicitly when resuming."""
@@ -0,0 +1,99 @@
1
+ """Sennet's own MCP endpoint, for agent CLIs that call back into it.
2
+
3
+ Claude Code asks a permission prompt tool, an MCP tool, before a tool call that
4
+ needs the user's say. The daemon serves such tools over HTTP at `/mcp/<secret>`,
5
+ with one secret per agent session: the adapter registers its tools when the
6
+ session starts, passes the URL to its CLI, and unregisters when it closes, which
7
+ abandons any call still waiting. Only a live session's secret is answered.
8
+
9
+ This is the least of MCP's HTTP transport a client needs to call a tool: JSON-RPC
10
+ requests in POST bodies, answered with one JSON response each, and no sessions,
11
+ streams or server-sent requests.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import secrets
18
+ from dataclasses import dataclass, field
19
+ from typing import Awaitable, Callable
20
+
21
+ from .. import __version__
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ PROTOCOL_VERSION = "2025-06-18"
26
+ SERVER_NAME = "sennet"
27
+
28
+ # A tool call: the tool's name and arguments in, its text result out.
29
+ ToolCall = Callable[[str, dict], Awaitable[str]]
30
+
31
+
32
+ @dataclass
33
+ class Registration:
34
+ secret: str
35
+ url: str
36
+ tools: list[dict] # MCP tool definitions: name, description, inputSchema
37
+ call: ToolCall
38
+ calls: set[asyncio.Task] = field(default_factory=set)
39
+
40
+
41
+ class CallbackServer:
42
+ def __init__(self, base_url: str = ""):
43
+ self.base_url = base_url # where the daemon listens, as its agents reach it
44
+ self.sessions: dict[str, Registration] = {}
45
+
46
+ def register(self, tools: list[dict], call: ToolCall) -> Registration:
47
+ secret = secrets.token_urlsafe(32)
48
+ registration = Registration(secret, f"{self.base_url}/mcp/{secret}", tools, call)
49
+ self.sessions[secret] = registration
50
+ return registration
51
+
52
+ async def unregister(self, registration: Registration):
53
+ self.sessions.pop(registration.secret, None)
54
+ for call in list(registration.calls):
55
+ call.cancel()
56
+ await asyncio.gather(*registration.calls, return_exceptions=True)
57
+
58
+ async def handle(self, secret: str, message: dict) -> dict | None:
59
+ """The JSON-RPC response to `message`, or None for a notification. LookupError for an unknown secret."""
60
+ registration = self.sessions.get(secret)
61
+ if registration is None:
62
+ raise LookupError("no such session")
63
+ method, request_id = message.get("method"), message.get("id")
64
+ if request_id is None:
65
+ return None
66
+ params = message.get("params") or {}
67
+ if method == "initialize":
68
+ result = {"protocolVersion": params.get("protocolVersion", PROTOCOL_VERSION),
69
+ "capabilities": {"tools": {}},
70
+ "serverInfo": {"name": SERVER_NAME, "title": "Sennet", "version": __version__}}
71
+ elif method == "tools/list":
72
+ result = {"tools": registration.tools}
73
+ elif method == "tools/call":
74
+ names = {tool["name"] for tool in registration.tools}
75
+ if params.get("name") not in names:
76
+ return _error(request_id, -32602, f"no tool called {params.get('name')!r}")
77
+ call = asyncio.ensure_future(registration.call(params["name"], params.get("arguments") or {}))
78
+ registration.calls.add(call)
79
+ try:
80
+ text = await call
81
+ except asyncio.CancelledError:
82
+ if registration.secret in self.sessions:
83
+ raise # the request itself was cancelled
84
+ return _error(request_id, -32603, "the session ended")
85
+ except Exception:
86
+ log.exception("tool %s failed", params["name"])
87
+ return _error(request_id, -32603, "internal error")
88
+ finally:
89
+ registration.calls.discard(call)
90
+ result = {"content": [{"type": "text", "text": text}]}
91
+ elif method in ("ping", "server/discover"):
92
+ result = {}
93
+ else:
94
+ return _error(request_id, -32601, f"method {method!r} isn't supported")
95
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
96
+
97
+
98
+ def _error(request_id, code: int, message: str) -> dict:
99
+ return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}