yeschef-cli 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.
- yeschef/__init__.py +3 -0
- yeschef/__main__.py +6 -0
- yeschef/agent/__init__.py +6 -0
- yeschef/agent/backends/__init__.py +53 -0
- yeschef/agent/backends/anthropic_compat.py +119 -0
- yeschef/agent/backends/base.py +50 -0
- yeschef/agent/backends/cli.py +99 -0
- yeschef/agent/backends/openai_compat.py +118 -0
- yeschef/agent/config.py +98 -0
- yeschef/agent/detect.py +98 -0
- yeschef/agent/harness.py +1009 -0
- yeschef/cli.py +1073 -0
- yeschef/hub/__init__.py +16 -0
- yeschef/hub/api.py +595 -0
- yeschef/hub/app.py +43 -0
- yeschef/hub/dashboard.html +206 -0
- yeschef/hub/events.py +78 -0
- yeschef/hub/mcp_server.py +941 -0
- yeschef/hub/schema.sql +106 -0
- yeschef/hub/store.py +1621 -0
- yeschef/models.py +431 -0
- yeschef/procs.py +109 -0
- yeschef/replay.py +224 -0
- yeschef/resources/__init__.py +0 -0
- yeschef/resources/agents/__init__.py +0 -0
- yeschef/resources/agents/yeschef-expediter.md +74 -0
- yeschef/resources/skill/SKILL.md +212 -0
- yeschef/resources/skill/__init__.py +0 -0
- yeschef/sdk/__init__.py +9 -0
- yeschef/sdk/client.py +359 -0
- yeschef/settings.py +100 -0
- yeschef/tools/__init__.py +5 -0
- yeschef/tools/executor.py +296 -0
- yeschef_cli-0.1.0.dist-info/METADATA +254 -0
- yeschef_cli-0.1.0.dist-info/RECORD +38 -0
- yeschef_cli-0.1.0.dist-info/WHEEL +4 -0
- yeschef_cli-0.1.0.dist-info/entry_points.txt +3 -0
- yeschef_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Worker-side tool execution.
|
|
2
|
+
|
|
3
|
+
Off by default. An agent gets only the tools its config names, shell commands must match
|
|
4
|
+
an explicit allowlist, and file access is jailed to one root. Everything runs on the
|
|
5
|
+
worker node; the hub never executes anything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import fnmatch
|
|
13
|
+
import ipaddress
|
|
14
|
+
import os
|
|
15
|
+
import shlex
|
|
16
|
+
import socket
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from ..models import ToolCall, ToolResult
|
|
24
|
+
|
|
25
|
+
MAX_OUTPUT_CHARS = 20_000
|
|
26
|
+
|
|
27
|
+
_POSIX_METACHARACTERS = (";", "&&", "||", "|", "`", "$(", ">", "<", "\n", "&")
|
|
28
|
+
# cmd.exe adds its own chaining and expansion characters; `create_subprocess_shell`
|
|
29
|
+
# uses cmd on Windows, so a worker there must screen for these too.
|
|
30
|
+
_WINDOWS_METACHARACTERS = _POSIX_METACHARACTERS + ("%", "^", "\r")
|
|
31
|
+
|
|
32
|
+
SHELL_METACHARACTERS = _WINDOWS_METACHARACTERS if os.name == "nt" else _POSIX_METACHARACTERS
|
|
33
|
+
"""Anything that could chain or redirect a second command past the allowlist."""
|
|
34
|
+
|
|
35
|
+
MAX_REDIRECTS = 3
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reject_internal_url(url: str) -> str | None:
|
|
39
|
+
"""Return a reason to refuse this URL, or None if it looks externally routable."""
|
|
40
|
+
parsed = urlparse(url)
|
|
41
|
+
if parsed.scheme not in ("http", "https"):
|
|
42
|
+
return "url must be http(s)"
|
|
43
|
+
if not parsed.hostname:
|
|
44
|
+
return "url has no host"
|
|
45
|
+
try:
|
|
46
|
+
infos = socket.getaddrinfo(parsed.hostname, parsed.port or 0, proto=socket.IPPROTO_TCP)
|
|
47
|
+
except socket.gaierror as exc:
|
|
48
|
+
return f"could not resolve host: {exc}"
|
|
49
|
+
for info in infos:
|
|
50
|
+
address = ipaddress.ip_address(info[4][0])
|
|
51
|
+
if (
|
|
52
|
+
address.is_private
|
|
53
|
+
or address.is_loopback
|
|
54
|
+
or address.is_link_local
|
|
55
|
+
or address.is_reserved
|
|
56
|
+
or address.is_multicast
|
|
57
|
+
or address.is_unspecified
|
|
58
|
+
):
|
|
59
|
+
return (
|
|
60
|
+
f"refusing to fetch an internal address ({address}); web_fetch reaches "
|
|
61
|
+
"the public internet only"
|
|
62
|
+
)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class ToolsConfig:
|
|
68
|
+
allow: list[str] = field(default_factory=list)
|
|
69
|
+
shell_allowlist: list[str] = field(default_factory=list)
|
|
70
|
+
file_root: str | None = None
|
|
71
|
+
timeout_s: float = 60.0
|
|
72
|
+
max_fetch_bytes: int = 2_000_000
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_dict(cls, raw: dict | None) -> ToolsConfig:
|
|
76
|
+
raw = raw or {}
|
|
77
|
+
return cls(
|
|
78
|
+
allow=list(raw.get("allow") or []),
|
|
79
|
+
shell_allowlist=list(raw.get("shell_allowlist") or []),
|
|
80
|
+
file_root=raw.get("file_root"),
|
|
81
|
+
timeout_s=float(raw.get("timeout_s", 60.0)),
|
|
82
|
+
max_fetch_bytes=int(raw.get("max_fetch_bytes", 2_000_000)),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
SPECS: dict[str, dict] = {
|
|
87
|
+
"shell": {
|
|
88
|
+
"name": "shell",
|
|
89
|
+
"description": "Run an allowlisted shell command on this node and return its output.",
|
|
90
|
+
"input_schema": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {"command": {"type": "string", "description": "Command to run"}},
|
|
93
|
+
"required": ["command"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
"file_read": {
|
|
97
|
+
"name": "file_read",
|
|
98
|
+
"description": "Read a UTF-8 text file from this node's agent workspace.",
|
|
99
|
+
"input_schema": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {"path": {"type": "string"}},
|
|
102
|
+
"required": ["path"],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
"file_write": {
|
|
106
|
+
"name": "file_write",
|
|
107
|
+
"description": "Write a UTF-8 text file into this node's agent workspace.",
|
|
108
|
+
"input_schema": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
|
111
|
+
"required": ["path", "content"],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
"file_list": {
|
|
115
|
+
"name": "file_list",
|
|
116
|
+
"description": "List files under a directory in this node's agent workspace.",
|
|
117
|
+
"input_schema": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {"path": {"type": "string", "default": "."}},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
"web_fetch": {
|
|
123
|
+
"name": "web_fetch",
|
|
124
|
+
"description": "Fetch a URL and return its body as text.",
|
|
125
|
+
"input_schema": {
|
|
126
|
+
"type": "object",
|
|
127
|
+
"properties": {"url": {"type": "string"}},
|
|
128
|
+
"required": ["url"],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ToolExecutor:
|
|
135
|
+
def __init__(self, config: ToolsConfig) -> None:
|
|
136
|
+
self.config = config
|
|
137
|
+
self.root = Path(config.file_root).expanduser().resolve() if config.file_root else None
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def enabled(self) -> bool:
|
|
141
|
+
return bool(self.config.allow)
|
|
142
|
+
|
|
143
|
+
def specs(self) -> list[dict]:
|
|
144
|
+
"""Tool schemas for the model, limited to what this agent may use."""
|
|
145
|
+
return [SPECS[name] for name in self.config.allow if name in SPECS]
|
|
146
|
+
|
|
147
|
+
async def run(self, call: ToolCall) -> ToolResult:
|
|
148
|
+
if call.name not in self.config.allow:
|
|
149
|
+
return self._error(call, f"tool '{call.name}' is not enabled for this agent")
|
|
150
|
+
handler = {
|
|
151
|
+
"shell": self._shell,
|
|
152
|
+
"file_read": self._file_read,
|
|
153
|
+
"file_write": self._file_write,
|
|
154
|
+
"file_list": self._file_list,
|
|
155
|
+
"web_fetch": self._web_fetch,
|
|
156
|
+
}.get(call.name)
|
|
157
|
+
if handler is None:
|
|
158
|
+
return self._error(call, f"unknown tool '{call.name}'")
|
|
159
|
+
# `shell` enforces its own deadline so it can kill the child; give the generic
|
|
160
|
+
# backstop extra room rather than racing it.
|
|
161
|
+
budget = self.config.timeout_s + (5.0 if call.name == "shell" else 0.0)
|
|
162
|
+
try:
|
|
163
|
+
return await asyncio.wait_for(handler(call), timeout=budget)
|
|
164
|
+
except TimeoutError:
|
|
165
|
+
return self._error(call, f"tool timed out after {self.config.timeout_s}s")
|
|
166
|
+
except Exception as exc: # noqa: BLE001 - surfaced to the model, not swallowed
|
|
167
|
+
return self._error(call, f"{type(exc).__name__}: {exc}")
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------- handlers
|
|
170
|
+
|
|
171
|
+
async def _shell(self, call: ToolCall) -> ToolResult:
|
|
172
|
+
command = str(call.arguments.get("command", "")).strip()
|
|
173
|
+
if not command:
|
|
174
|
+
return self._error(call, "command is required")
|
|
175
|
+
if not self._shell_allowed(command):
|
|
176
|
+
return self._error(
|
|
177
|
+
call,
|
|
178
|
+
f"command not allowed. Allowlist: {self.config.shell_allowlist or '(empty)'}",
|
|
179
|
+
)
|
|
180
|
+
proc = await asyncio.create_subprocess_shell(
|
|
181
|
+
command,
|
|
182
|
+
stdout=asyncio.subprocess.PIPE,
|
|
183
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
184
|
+
cwd=str(self.root) if self.root else None,
|
|
185
|
+
)
|
|
186
|
+
try:
|
|
187
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=self.config.timeout_s)
|
|
188
|
+
except TimeoutError:
|
|
189
|
+
# Cancelling the wait leaves the process running; kill it or the node
|
|
190
|
+
# accumulates an orphan per timed-out call.
|
|
191
|
+
proc.kill()
|
|
192
|
+
with contextlib.suppress(Exception):
|
|
193
|
+
await proc.wait()
|
|
194
|
+
return self._error(call, f"command timed out after {self.config.timeout_s}s (killed)")
|
|
195
|
+
text = stdout.decode(errors="replace")[:MAX_OUTPUT_CHARS]
|
|
196
|
+
return ToolResult(
|
|
197
|
+
call_id=call.id,
|
|
198
|
+
name=call.name,
|
|
199
|
+
content=f"exit={proc.returncode}\n{text}",
|
|
200
|
+
is_error=proc.returncode != 0,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _shell_allowed(self, command: str) -> bool:
|
|
204
|
+
"""Match against the allowlist without letting a wildcard smuggle in a second command.
|
|
205
|
+
|
|
206
|
+
A pattern like `rg *` is meant to allow ripgrep, not `rg x; rm -rf /`, and plain
|
|
207
|
+
fnmatch matches both. So a command carrying shell metacharacters is only accepted
|
|
208
|
+
by a pattern that carries one itself — checked **per matching pattern**, because a
|
|
209
|
+
single deliberate pipeline elsewhere in the list must not re-open chaining for
|
|
210
|
+
every other entry.
|
|
211
|
+
"""
|
|
212
|
+
if not self.config.shell_allowlist:
|
|
213
|
+
return False
|
|
214
|
+
try:
|
|
215
|
+
# Windows paths use backslashes that POSIX-mode shlex mangles; validate
|
|
216
|
+
# parseability in the mode that matches the shell we'll actually invoke.
|
|
217
|
+
shlex.split(command, posix=(os.name != "nt"))
|
|
218
|
+
except ValueError:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
command_chains = any(token in command for token in SHELL_METACHARACTERS)
|
|
222
|
+
for pattern in self.config.shell_allowlist:
|
|
223
|
+
if not fnmatch.fnmatch(command, pattern):
|
|
224
|
+
continue
|
|
225
|
+
if command_chains and not any(token in pattern for token in SHELL_METACHARACTERS):
|
|
226
|
+
continue # this pattern authorized one command, not a chain
|
|
227
|
+
return True
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
def _resolve(self, raw: str) -> Path:
|
|
231
|
+
if self.root is None:
|
|
232
|
+
raise ValueError("file tools need [tools] file_root to be set")
|
|
233
|
+
if raw.startswith("~"):
|
|
234
|
+
# Treating '~' as a literal filename produced opaque FileNotFoundErrors;
|
|
235
|
+
# say what is actually possible instead.
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"'~' is not expanded here — your workspace is {self.root}; "
|
|
238
|
+
"use paths relative to it"
|
|
239
|
+
)
|
|
240
|
+
target = (self.root / raw).resolve() if not Path(raw).is_absolute() else Path(raw).resolve()
|
|
241
|
+
if target != self.root and self.root not in target.parents:
|
|
242
|
+
raise ValueError(f"path escapes the agent workspace ({self.root})")
|
|
243
|
+
return target
|
|
244
|
+
|
|
245
|
+
async def _file_read(self, call: ToolCall) -> ToolResult:
|
|
246
|
+
target = self._resolve(str(call.arguments.get("path", "")))
|
|
247
|
+
text = await asyncio.to_thread(target.read_text, "utf-8")
|
|
248
|
+
return ToolResult(call.id, call.name, text[:MAX_OUTPUT_CHARS])
|
|
249
|
+
|
|
250
|
+
async def _file_write(self, call: ToolCall) -> ToolResult:
|
|
251
|
+
target = self._resolve(str(call.arguments.get("path", "")))
|
|
252
|
+
content = str(call.arguments.get("content", ""))
|
|
253
|
+
|
|
254
|
+
def _write() -> None:
|
|
255
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
target.write_text(content, "utf-8")
|
|
257
|
+
|
|
258
|
+
await asyncio.to_thread(_write)
|
|
259
|
+
return ToolResult(call.id, call.name, f"wrote {len(content)} bytes to {target}")
|
|
260
|
+
|
|
261
|
+
async def _file_list(self, call: ToolCall) -> ToolResult:
|
|
262
|
+
target = self._resolve(str(call.arguments.get("path", ".")))
|
|
263
|
+
entries = sorted(
|
|
264
|
+
f"{'d' if p.is_dir() else '-'} {p.relative_to(self.root)}" for p in target.iterdir()
|
|
265
|
+
)
|
|
266
|
+
return ToolResult(call.id, call.name, "\n".join(entries)[:MAX_OUTPUT_CHARS] or "(empty)")
|
|
267
|
+
|
|
268
|
+
async def _web_fetch(self, call: ToolCall) -> ToolResult:
|
|
269
|
+
"""Fetch a public URL.
|
|
270
|
+
|
|
271
|
+
Redirects are followed by hand so every hop is re-checked: otherwise a public URL
|
|
272
|
+
could bounce the worker into the hub's own API, a node's model server, or a cloud
|
|
273
|
+
metadata endpoint.
|
|
274
|
+
"""
|
|
275
|
+
url = str(call.arguments.get("url", ""))
|
|
276
|
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
|
|
277
|
+
for _ in range(MAX_REDIRECTS + 1):
|
|
278
|
+
problem = _reject_internal_url(url)
|
|
279
|
+
if problem:
|
|
280
|
+
return self._error(call, problem)
|
|
281
|
+
response = await client.get(url)
|
|
282
|
+
if response.is_redirect and response.headers.get("location"):
|
|
283
|
+
url = str(response.next_request.url) if response.next_request else ""
|
|
284
|
+
continue
|
|
285
|
+
response.raise_for_status()
|
|
286
|
+
body = b""
|
|
287
|
+
async for chunk in response.aiter_bytes():
|
|
288
|
+
body += chunk
|
|
289
|
+
if len(body) >= self.config.max_fetch_bytes:
|
|
290
|
+
break
|
|
291
|
+
text = body[: self.config.max_fetch_bytes].decode(errors="replace")
|
|
292
|
+
return ToolResult(call.id, call.name, text[:MAX_OUTPUT_CHARS])
|
|
293
|
+
return self._error(call, "too many redirects")
|
|
294
|
+
|
|
295
|
+
def _error(self, call: ToolCall, message: str) -> ToolResult:
|
|
296
|
+
return ToolResult(call.id, call.name, f"error: {message}", is_error=True)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: yeschef-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The farm team for Claude Code and Codex: local models on your own hardware that take the grunt work, return the files they build, and never hit a rate limit
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: agents,claude-code,local-llm,mcp,ollama,self-hosted,vllm
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: fastapi>=0.115
|
|
10
|
+
Requires-Dist: fastmcp>=2.10
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: rich>=13.9
|
|
13
|
+
Requires-Dist: typer>=0.15
|
|
14
|
+
Requires-Dist: uvicorn[standard]>=0.32
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: asgi-lifespan>=2.1; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# 🍳 yeschef
|
|
23
|
+
|
|
24
|
+
**A kitchen for Claude Code and Codex.** Local models on your own hardware — your line
|
|
25
|
+
of *cooks* — that take the grunt work, talk it out in bounded rooms, and never hit a
|
|
26
|
+
rate limit. You call the order; a cook works the ticket; the plate comes back.
|
|
27
|
+
|
|
28
|
+
You pay per token and wait out rate limits while the GPUs you already own sit idle.
|
|
29
|
+
yeschef turns them into a line Claude Code can actually run: fire a ticket, keep working,
|
|
30
|
+
check the pass whenever — from any session, days later. You're the chef doing the
|
|
31
|
+
thinking; the kitchen does the reps.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
*One prompt, live session: Claude shows the line — every cook's model and machine
|
|
36
|
+
(`grill · ollama/qwen2.5:7b-instruct @ miner`) — fires the buildout, and spawns the
|
|
37
|
+
`yeschef-expediter` subagent, visible in the native panel like any subagent. The session
|
|
38
|
+
comes straight back to you; when the cook finishes, the expediter calls it back on its
|
|
39
|
+
own, plates the files in `./site`, and Claude tastes the cook's work against the spec.
|
|
40
|
+
No hovering, no "is it up yet."*
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
Claude Code ──MCP/HTTP──▶ ┌─────────┐ ◀──REST+SSE── cook ──▶ Ollama (office-mac)
|
|
44
|
+
(any session, │ hub │ ◀──REST+SSE── cook ──▶ vLLM (gpu-box)
|
|
45
|
+
any machine) │ SQLite │ ◀──REST+SSE── cook ──▶ any /v1 (spare-pc)
|
|
46
|
+
│ (pass) │
|
|
47
|
+
└─────────┘
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| | Without a kitchen | With one |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| Grunt work (summarize, classify, extract, triage) | burns your Claude tokens | cooks on your hardware, $0 marginal |
|
|
53
|
+
| Rate-limited or throttled | you wait | the line keeps cooking |
|
|
54
|
+
| Long-running work | blocks the session, dies with it | fired to the background; ticket durable in SQLite, checkable from any session |
|
|
55
|
+
| A second opinion | another API call | two of your machines talk it over in a bounded room while you watch — and cut in |
|
|
56
|
+
|
|
57
|
+
## Two commands
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv tool install yeschef-cli # or: pipx install yeschef-cli — the command is `yeschef`
|
|
61
|
+
|
|
62
|
+
# 1. On the hub machine (where you run Claude Code) — generates tokens,
|
|
63
|
+
# wires Claude Code, prints the clock-in line:
|
|
64
|
+
yeschef up
|
|
65
|
+
|
|
66
|
+
# 2. On each machine that will cook — auto-detects Ollama / vLLM / LM Studio,
|
|
67
|
+
# verifies the model answers, clocks the cook in. Paste the line `up` printed:
|
|
68
|
+
yeschef join --hub http://hub-host:8787 --token <printed-by-up>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That's the whole setup. Single-box demo: run both on one machine — `join` needs no flags
|
|
72
|
+
at all. `yeschef doctor` diagnoses anything that's off; `--detach`/`down`/`ps` run it all
|
|
73
|
+
in the background from one terminal. macOS, Linux, and Windows.
|
|
74
|
+
|
|
75
|
+
`yeschef up` wires **both Claude Code and Codex** if they're installed — Claude Code over
|
|
76
|
+
HTTP MCP, Codex over a stdio bridge (`yeschef mcp-proxy`). Same tools, same kitchen,
|
|
77
|
+
either client.
|
|
78
|
+
|
|
79
|
+
**Or let Claude Code install it.** This repo is also a Claude Code plugin:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
/plugin marketplace add labscommunity/yeschef
|
|
83
|
+
/plugin install yeschef@yeschef
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then say "set up yeschef" (or run `/yeschef:setup`) and Claude installs the CLI, opens
|
|
87
|
+
the kitchen, wires the MCP connection, and hands you the cook clock-in line. The plugin
|
|
88
|
+
also ships the skill and the `yeschef-expediter` subagent automatically.
|
|
89
|
+
|
|
90
|
+
## What it feels like
|
|
91
|
+
|
|
92
|
+
From Claude Code (wired automatically by `up`) — you just talk to it: *"fire the log
|
|
93
|
+
triage to a fast cook,"* *"have a cook draft the release notes,"* *"send the buildout to
|
|
94
|
+
the line."* Under the hood:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
> list_agents # your line, live
|
|
98
|
+
> submit_task("label these 400 log lines", selector="tier:fast")
|
|
99
|
+
→ ticket_9f3k2p # fires immediately
|
|
100
|
+
> task_status("ticket_9f3k2p") # any time, any session
|
|
101
|
+
> task_result("ticket_9f3k2p")
|
|
102
|
+
→ … lifetime: 62 tickets · ~1.2M tokens cooked locally · 9.4h on your hardware
|
|
103
|
+
> start_dialogue(["saucier", "grill"], goal="agree on a cache eviction policy")
|
|
104
|
+
> room_transcript("room_x7c2") # watch them work it out
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
From your terminal:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
yeschef ask saucier "what's in today's error log?" # send, wait, print the reply
|
|
111
|
+
yeschef submit "draft release notes from this diff" --to grill
|
|
112
|
+
yeschef dialogue saucier grill --goal "pick a caching strategy" # follows it live
|
|
113
|
+
yeschef watch room_x7c2 --record demo.json # save a transcript
|
|
114
|
+
yeschef replay demo.json # re-stream it, real pacing
|
|
115
|
+
yeschef stats # what the kitchen has cooked for you
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The talking-it-out is the part you have to see: two of your machines, in different
|
|
119
|
+
colors, working a problem — and you can cut into the middle of it. Every room is
|
|
120
|
+
**bounded by construction** (message caps, token budgets, idle timeouts, stop phrases —
|
|
121
|
+
enforced by the hub, not by hoping), so nothing loops forever.
|
|
122
|
+
|
|
123
|
+

|
|
124
|
+
|
|
125
|
+
*Real conversation, not a script. Two cooks on one GPU box argue about what this demo
|
|
126
|
+
should show — until the chef cuts in and overrules them both. The transcript ships in
|
|
127
|
+
this repo; after install, watch the identical conversation yourself with
|
|
128
|
+
`yeschef replay docs/demo.json`.*
|
|
129
|
+
|
|
130
|
+
## Fire a buildout, get the dishes back
|
|
131
|
+
|
|
132
|
+
Every ticket with a workspace returns what it plates. A cook with file tools writes into
|
|
133
|
+
a per-ticket jail; a cook with the **`cli` backend** hands the whole ticket to a real
|
|
134
|
+
coding agent — the flagship config runs the full Claude Code harness against your own
|
|
135
|
+
local model:
|
|
136
|
+
|
|
137
|
+
```toml
|
|
138
|
+
[backend]
|
|
139
|
+
type = "cli"
|
|
140
|
+
command = ["claude", "-p", "{prompt}", "--dangerously-skip-permissions"]
|
|
141
|
+
[backend.env]
|
|
142
|
+
ANTHROPIC_BASE_URL = "http://localhost:11434" # Ollama v0.14+ speaks Anthropic
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Deep agentic loop, zero API tokens, and everything it plates ships back through the pass:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
> submit_task("build the landing page from DESIGN.md", assignee="line-cook")
|
|
149
|
+
> wait_task("ticket_ab12cd") # one call/min, returns early when it's up
|
|
150
|
+
> task_files("ticket_ab12cd") # index.html · css/styles.css
|
|
151
|
+
> task_file("ticket_ab12cd", "index.html") # pull it, write it into the repo
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**See it on the rail (the subagent panel).** `yeschef install-skill` also installs the
|
|
155
|
+
`yeschef-expediter` subagent: spawn it in the background after firing a ticket and the
|
|
156
|
+
cook's job shows up in Claude Code's native panel like any subagent — the expo watches
|
|
157
|
+
it efficiently, plates the returned files into the project, and calls the result back
|
|
158
|
+
when the cook is done.
|
|
159
|
+
|
|
160
|
+
## Isn't this what Agent Teams does?
|
|
161
|
+
|
|
162
|
+
Complementary, not competing. Native subagents and teams are Claude-only, same billing,
|
|
163
|
+
same cloud — excellent at parallel *thinking*. A kitchen is the other half:
|
|
164
|
+
**heterogeneous cooks that are free after hardware**, that keep going when you're
|
|
165
|
+
throttled, and whose ticket status outlives any session. Claude Code stays the chef and
|
|
166
|
+
fires the verifiable grunt work down the line.
|
|
167
|
+
|
|
168
|
+
## What a cook is
|
|
169
|
+
|
|
170
|
+
`yeschef join` clocks one in from whatever model server it finds. A TOML config (see
|
|
171
|
+
[`examples/agents/`](examples/agents/)) is for pinning a persona, capability tags
|
|
172
|
+
(`tier:fast`, `tier:reasoning` — fire by tag and the first free match takes the ticket),
|
|
173
|
+
or **opt-in tools**: shell (pattern-allowlisted, metacharacter-screened), file access
|
|
174
|
+
(jailed to one directory), web fetch (refuses internal addresses) — always executed on
|
|
175
|
+
the cook's machine, never the hub. Anything embedding the Python SDK
|
|
176
|
+
(`yeschef.sdk.AgentClient`) is a first-class cook too.
|
|
177
|
+
|
|
178
|
+
**Cloud cooks, too.** A cook is just an OpenAI- or Anthropic-compatible client, so a
|
|
179
|
+
hosted provider works exactly like a local one — `yeschef join` has presets:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
export OPENROUTER_API_KEY=sk-or-...
|
|
183
|
+
yeschef join --provider openrouter --model meta-llama/llama-3.3-70b-instruct --tier fast
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Presets: `openrouter`, `openai`, `groq`, `together`, `deepseek`, `fireworks`; any other
|
|
187
|
+
endpoint works with `--base-url` + `--api-key-env`. Mix cloud and local on one hub and
|
|
188
|
+
fire by tag — send bulk grunt work to a cheap cloud cook, keep the private work on your
|
|
189
|
+
own machines.
|
|
190
|
+
|
|
191
|
+
## Honesty ledger
|
|
192
|
+
|
|
193
|
+
What this is, and isn't:
|
|
194
|
+
|
|
195
|
+
- **Local cooks are slower and weaker than Claude** — often 3–30× slower per token.
|
|
196
|
+
Firing work out wins on bounded, verifiable jobs (bulk transforms, drafts, triage,
|
|
197
|
+
second opinions), not on deep reasoning. That's why the design keeps the chef in charge.
|
|
198
|
+
- **Total cost isn't $0.** It's your electricity and your hardware. What it isn't is
|
|
199
|
+
metered, throttled, or revocable.
|
|
200
|
+
- **A cloud cook (OpenRouter etc.) is the opposite trade** — metered, and your data
|
|
201
|
+
leaves your network. It's supported and useful (cheap bulk work, models you can't run
|
|
202
|
+
locally), but it's not the "own your hardware" default. Choose per cook.
|
|
203
|
+
- **No TLS termination** — the hub is designed for LAN/Tailscale (which encrypts
|
|
204
|
+
transport). Never expose it through a public funnel or port-forward.
|
|
205
|
+
- **Talking-it-out is real model output, not magic.** Small models sometimes say dull
|
|
206
|
+
things. The `--record`/`replay` pipeline exists so demos are replays of real
|
|
207
|
+
conversations, not scripts.
|
|
208
|
+
- Windows support is tested in CI logic-paths but has had less real-world mileage than
|
|
209
|
+
macOS/Linux. Reports welcome.
|
|
210
|
+
|
|
211
|
+
## Security posture
|
|
212
|
+
|
|
213
|
+
Auth is on by default (`up` generates tokens; `--open` exists for trusted LANs and says
|
|
214
|
+
so loudly). Cooks hold per-cook bearer tokens; a registered name can't be hijacked
|
|
215
|
+
without its token; reads are scoped to the caller once auth is on; cooks can't
|
|
216
|
+
self-register as privileged kinds; every autonomous room is bounded; cook tools are
|
|
217
|
+
opt-in, allowlisted, and jailed. Full details in [SPEC.md](SPEC.md).
|
|
218
|
+
|
|
219
|
+
## Running the line
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
yeschef cooks / tasks / task <id> / result <id> / rooms / watch <room> / cancel <id>
|
|
223
|
+
yeschef stats # lifetime: tickets cooked, tokens cooked locally, hours worked
|
|
224
|
+
yeschef ps / down # background processes on this machine
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A background sweep requeues tickets from lost cooks, times out overruns, archives idle
|
|
228
|
+
rooms, and un-sticks stalled conversations. Persistent-service units for launchd,
|
|
229
|
+
systemd, and Windows are in [`examples/deploy/`](examples/deploy/).
|
|
230
|
+
|
|
231
|
+
## For the cooks (and Claude)
|
|
232
|
+
|
|
233
|
+
Claude Code sessions get tool docs automatically over MCP. To teach a session *when* to
|
|
234
|
+
fire work out (not just how), install the bundled skill: `yeschef install-skill` (or
|
|
235
|
+
`--user` for all projects). The full agent-facing guide is [docs/AGENTS.md](docs/AGENTS.md).
|
|
236
|
+
|
|
237
|
+
## Standing on
|
|
238
|
+
|
|
239
|
+
yeschef is a client of the local-inference ecosystem, not a fork of it: it talks to
|
|
240
|
+
[Ollama](https://github.com/ollama/ollama) (and the
|
|
241
|
+
[llama.cpp](https://github.com/ggml-org/llama.cpp) engine underneath it),
|
|
242
|
+
[vLLM](https://github.com/vllm-project/vllm), LM Studio, and any OpenAI- or
|
|
243
|
+
Anthropic-compatible endpoint. The MCP server is built on
|
|
244
|
+
[FastMCP](https://github.com/jlowin/fastmcp). Those projects do the hard part.
|
|
245
|
+
|
|
246
|
+
## Development
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
uv venv && uv pip install -e ".[dev]"
|
|
250
|
+
uv run pytest # thread-level race regressions included
|
|
251
|
+
uv run ruff check src tests
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
MIT. Formerly `farmteam` / `cascadia-tasks` — the old CLI names still work as aliases.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
yeschef/__init__.py,sha256=_FzcQLIPygZgFSLBqnkkdkc_fW2Wq2k6cIfqXrb1KgU,86
|
|
2
|
+
yeschef/__main__.py,sha256=ZsTB2n1AVKtQPYdbiT6mXszWhayaEbpCykwoVNP3nOI,134
|
|
3
|
+
yeschef/cli.py,sha256=STggBHWTL2_NbDnKbaNHzg2uZXxXEV_SncGYi3PfNvc,40970
|
|
4
|
+
yeschef/models.py,sha256=pTef0O7s7nemynn8n9vsvGwN-lrgKLyOgKvIsA3W9DE,12960
|
|
5
|
+
yeschef/procs.py,sha256=mfhJs84bruJjpWhND4TP4X6f2zpYFQweCekMIJ3Lpmg,3242
|
|
6
|
+
yeschef/replay.py,sha256=4M5uHoo4LUMfGHJ8e6kk0uJCX0cH5rB-MHwRRCPaXog,8535
|
|
7
|
+
yeschef/settings.py,sha256=nh44-U-H5sda_ukSi_jUkZlb8s1WYtqIa8sG-9LlOIA,3243
|
|
8
|
+
yeschef/agent/__init__.py,sha256=Lf-ZyY1EhBVz4DmL2zb1F5yllRi3NZU2t5zXcqNo-X8,200
|
|
9
|
+
yeschef/agent/config.py,sha256=yp_HDX5p8_HeDc9vuvbIs0cjKkqok44hF9q4Lj_GsOU,3768
|
|
10
|
+
yeschef/agent/detect.py,sha256=exniaRT7QzjMwTT_wmNpmFcyjMpMSMzNlopYifjybe0,3529
|
|
11
|
+
yeschef/agent/harness.py,sha256=kC6TH1oRy5Pt1ZF4EZRrYR-4lNxgdf-ISBWS4w889pw,47671
|
|
12
|
+
yeschef/agent/backends/__init__.py,sha256=FrItIQzaacHdUqxU0sLP0pn9glkd-kDiFcPgo2J_CZw,1690
|
|
13
|
+
yeschef/agent/backends/anthropic_compat.py,sha256=ILpzc8GNuG84mJqsW_oi_TeJusc-arVJqC3pL9kgbwQ,4094
|
|
14
|
+
yeschef/agent/backends/base.py,sha256=oUOTmhv6PbNen4EvTCPibua_GSycuxT1GG3gx7VFfYU,1214
|
|
15
|
+
yeschef/agent/backends/cli.py,sha256=2YIcBvDhBxAAzq7O3qITkUU52KSHJWJmsDMjzyHBIe0,3580
|
|
16
|
+
yeschef/agent/backends/openai_compat.py,sha256=RS_o_439By--lIa3_k04StRJc2BFcS3L68bbMpEOeIE,4097
|
|
17
|
+
yeschef/hub/__init__.py,sha256=17mFrf-B-RaW6hVf3j0TbYl9lbhAodOraJo6zbIqu4U,364
|
|
18
|
+
yeschef/hub/api.py,sha256=FTyWJA2HvyPcBDOlQ2AhFpyNhIq66vcPsmI7qcV2zdo,22776
|
|
19
|
+
yeschef/hub/app.py,sha256=bUrIFgRlYQ1Fcgy_5upalt_Q7YF1E2KXQ2st2MKo73Y,1184
|
|
20
|
+
yeschef/hub/dashboard.html,sha256=aMzE20sZlg9n4qDCM_rOVrx83b7whXtwzyEO6mgWHjI,10532
|
|
21
|
+
yeschef/hub/events.py,sha256=bEEzk7Sc1Y1NdFmcMBb7XmxNlpRL_Vfy4NzPKt2M_Bg,2952
|
|
22
|
+
yeschef/hub/mcp_server.py,sha256=r38siZ2CTisv3-HX2uAEo40snlgAuti_bpCoaof-vlk,40293
|
|
23
|
+
yeschef/hub/schema.sql,sha256=Afe91MvciRaUt0VJSMfYLi9r9Qi6Pe1mpo_VsBzTgZg,3356
|
|
24
|
+
yeschef/hub/store.py,sha256=k9H8JCCktVJXdcOcmH8vd3ECgAgUWS7Pmu87TXrTEoM,68630
|
|
25
|
+
yeschef/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
yeschef/resources/agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
+
yeschef/resources/agents/yeschef-expediter.md,sha256=ijVcidF3Vr9cm5LxJL-piMFFuE8St6v3UJ5KngxjEAc,4957
|
|
28
|
+
yeschef/resources/skill/SKILL.md,sha256=4u7CK9dp38VsIIsrfZ-97SaqAoqRuYVIzyLoEu4T30g,13918
|
|
29
|
+
yeschef/resources/skill/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
30
|
+
yeschef/sdk/__init__.py,sha256=ZtfcfQjVoqTIHLlJRRsNRs2We_zRRRahYAr7DjQIdqo,325
|
|
31
|
+
yeschef/sdk/client.py,sha256=ZhFkbah6vUlXnz7psMeq8DbcR58tPsBkduwvDFMt_zc,13236
|
|
32
|
+
yeschef/tools/__init__.py,sha256=i7mYSa_6OuNy2el6VT4R8SIVTSvts3CkBTGMlaJcfs8,176
|
|
33
|
+
yeschef/tools/executor.py,sha256=M9WRW-teahdQOjRS7bZGnsUEYYNpFIuRCgKG_Ko2EeE,12043
|
|
34
|
+
yeschef_cli-0.1.0.dist-info/METADATA,sha256=k0iVWWML2N8A9ALrGRzCES7aB2C56kA_9fylB2TG6Kk,12227
|
|
35
|
+
yeschef_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
36
|
+
yeschef_cli-0.1.0.dist-info/entry_points.txt,sha256=JDIbMQTZULDSTnMUXWaxK7aN-GASNPLDLU5qWKhUza4,86
|
|
37
|
+
yeschef_cli-0.1.0.dist-info/licenses/LICENSE,sha256=qCXJLZ3T51pEpmH820VebKUnXAC-PJvDrVRZL5Mjcog,1077
|
|
38
|
+
yeschef_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yeschef contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|