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.
- sennet/__init__.py +3 -0
- sennet/__main__.py +3 -0
- sennet/access.py +121 -0
- sennet/agents/__init__.py +0 -0
- sennet/agents/base.py +315 -0
- sennet/agents/callbacks.py +99 -0
- sennet/agents/claude.py +531 -0
- sennet/agents/codex.py +518 -0
- sennet/agents/registry.py +44 -0
- sennet/aio.py +21 -0
- sennet/approvals.py +191 -0
- sennet/cli.py +442 -0
- sennet/config.py +162 -0
- sennet/db.py +457 -0
- sennet/events.py +93 -0
- sennet/install.py +166 -0
- sennet/projects.py +116 -0
- sennet/prompts.py +199 -0
- sennet/search.py +96 -0
- sennet/server.py +567 -0
- sennet/service.py +322 -0
- sennet/setup.py +568 -0
- sennet/speech/__init__.py +9 -0
- sennet/speech/base.py +84 -0
- sennet/speech/fakes.py +68 -0
- sennet/speech/hardware.py +86 -0
- sennet/speech/kokoro_vocab.json +116 -0
- sennet/speech/models.py +207 -0
- sennet/speech/preview.py +37 -0
- sennet/speech/runtime.py +68 -0
- sennet/speech/service.py +139 -0
- sennet/speech/stt.py +106 -0
- sennet/speech/tts.py +108 -0
- sennet/speech/turn.py +72 -0
- sennet/speech/vad.py +38 -0
- sennet/speech/voices.py +46 -0
- sennet/speech/worker.py +57 -0
- sennet/tailscale.py +82 -0
- sennet/tasks.py +584 -0
- sennet/usage.py +141 -0
- sennet/voice/__init__.py +5 -0
- sennet/voice/conversation.py +302 -0
- sennet/voice/endpoint.py +115 -0
- sennet/voice/frames.py +121 -0
- sennet/voice/hub.py +356 -0
- sennet/voice/opus.py +149 -0
- sennet/voice/recognition.py +141 -0
- sennet/voice/session.py +623 -0
- sennet/voice/speakable.py +243 -0
- sennet/voice/summary.py +35 -0
- sennet/web/apple-touch-icon.png +0 -0
- sennet/web/assets/approval-chime-BVC0aYXp.mp3 +0 -0
- sennet/web/assets/capture.worklet-B_5sKcQZ.js +1 -0
- sennet/web/assets/capture.worklet-B_5sKcQZ.js.gz +0 -0
- sennet/web/assets/index-C3BNiJz0.js +43 -0
- sennet/web/assets/index-C3BNiJz0.js.gz +0 -0
- sennet/web/assets/index-sDIJj-RH.css +2 -0
- sennet/web/assets/index-sDIJj-RH.css.gz +0 -0
- sennet/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- sennet/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- sennet/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- sennet/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- sennet/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- sennet/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- sennet/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- sennet/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- sennet/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- sennet/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- sennet/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- sennet/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- sennet/web/assets/newsreader-latin-ext-opsz-italic-CSPGnFdO.woff2 +0 -0
- sennet/web/assets/newsreader-latin-ext-opsz-normal-BQn1nviT.woff2 +0 -0
- sennet/web/assets/newsreader-latin-opsz-italic-kJKFiXvB.woff2 +0 -0
- sennet/web/assets/newsreader-latin-opsz-normal-s-izfB6B.woff2 +0 -0
- sennet/web/assets/newsreader-vietnamese-opsz-italic-DTr-ymY4.woff2 +0 -0
- sennet/web/assets/newsreader-vietnamese-opsz-normal-BjhtXyW2.woff2 +0 -0
- sennet/web/assets/playback.worklet-bBWNKQyb.js +1 -0
- sennet/web/assets/playback.worklet-bBWNKQyb.js.gz +0 -0
- sennet/web/assets/wake-chime-DUVL_Hl-.mp3 +0 -0
- sennet/web/assets/wakeword.worker-CbCH--VP.js +12 -0
- sennet/web/assets/wakeword.worker-CbCH--VP.js.gz +0 -0
- sennet/web/favicon.svg +1 -0
- sennet/web/icon-192.png +0 -0
- sennet/web/icon-512.png +0 -0
- sennet/web/index.html +20 -0
- sennet/web/licenses.txt +2787 -0
- sennet/web/licenses.txt.gz +0 -0
- sennet/web/manifest.webmanifest +13 -0
- sennet/web/notices.txt +194 -0
- sennet/web/notices.txt.gz +0 -0
- sennet-0.1.0.dist-info/METADATA +115 -0
- sennet-0.1.0.dist-info/RECORD +96 -0
- sennet-0.1.0.dist-info/WHEEL +4 -0
- sennet-0.1.0.dist-info/entry_points.txt +2 -0
- sennet-0.1.0.dist-info/licenses/LICENSE +202 -0
- sennet-0.1.0.dist-info/licenses/NOTICES +194 -0
sennet/__init__.py
ADDED
sennet/__main__.py
ADDED
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}}
|