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.
@@ -0,0 +1,16 @@
1
+ """Hub: SQLite store, agent REST/SSE API, and the FastMCP tool surface."""
2
+
3
+ from .api import HubConfig, create_app
4
+ from .app import app_factory, build_hub, config_from_env
5
+ from .events import EventBus
6
+ from .store import Store
7
+
8
+ __all__ = [
9
+ "EventBus",
10
+ "HubConfig",
11
+ "Store",
12
+ "app_factory",
13
+ "build_hub",
14
+ "config_from_env",
15
+ "create_app",
16
+ ]
yeschef/hub/api.py ADDED
@@ -0,0 +1,595 @@
1
+ """Agent-facing REST + SSE API.
2
+
3
+ Authentication: agents present the bearer token minted at registration. Admin token (if
4
+ configured) unlocks registration and the cross-identity `/watch` stream.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import json
12
+ from collections.abc import AsyncIterator
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Annotated, Any
16
+
17
+ from fastapi import APIRouter, Body, Depends, FastAPI, Header, Query, Request
18
+ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
19
+
20
+ from ..models import (
21
+ DEFAULT_TASK_TIMEOUT_S,
22
+ MAX_LONG_POLL_S,
23
+ AgentKind,
24
+ ErrorCode,
25
+ HubError,
26
+ RoomPolicy,
27
+ TaskState,
28
+ now,
29
+ )
30
+ from .store import Store
31
+
32
+ SSE_KEEPALIVE_S = 15.0
33
+
34
+ # Must live at module scope: `from __future__ import annotations` turns every annotation
35
+ # into a string, and FastAPI resolves those against module globals only.
36
+ AuthHeader = Annotated[str | None, Header(alias="Authorization")]
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class HubConfig:
41
+ db_path: str = "~/.yeschef/hub.db"
42
+ admin_token: str | None = None
43
+ register_token: str | None = None
44
+ sweep_interval_s: float = 10.0
45
+ default_identity: str = "claude:local"
46
+ cors_origins: list[str] = field(default_factory=list)
47
+
48
+
49
+ def _bearer(header: str | None) -> str | None:
50
+ if not header:
51
+ return None
52
+ scheme, _, token = header.partition(" ")
53
+ if scheme.lower() != "bearer" or not token:
54
+ return None
55
+ return token.strip()
56
+
57
+
58
+ class Auth:
59
+ """Resolves the calling identity for agent-facing routes.
60
+
61
+ A hub with no tokens configured runs open on the LAN (and says so at startup). As
62
+ soon as any token is configured, reads and admin actions require one — otherwise
63
+ task specs and room transcripts would stay world-readable on a hub the operator
64
+ believes is locked down.
65
+ """
66
+
67
+ def __init__(self, store: Store, config: HubConfig) -> None:
68
+ self.store = store
69
+ self.config = config
70
+
71
+ @property
72
+ def enforced(self) -> bool:
73
+ return self.config.admin_token is not None or self.config.register_token is not None
74
+
75
+ def is_privileged(self, header: str | None) -> bool:
76
+ token = _bearer(header)
77
+ return token is not None and token == self.config.admin_token
78
+
79
+ def admin(self, header: str | None) -> None:
80
+ if self.config.admin_token is None:
81
+ return
82
+ if _bearer(header) != self.config.admin_token:
83
+ raise HubError(ErrorCode.UNAUTHORIZED, "admin token required", 401)
84
+
85
+ def reader(self, header: str | None) -> str | None:
86
+ """Any valid identity may read. Returns the caller's name, or None in open mode."""
87
+ if not self.enforced:
88
+ return None
89
+ token = _bearer(header)
90
+ if token is None:
91
+ raise HubError(ErrorCode.UNAUTHORIZED, "bearer token required", 401)
92
+ if token == self.config.admin_token:
93
+ return "admin"
94
+ name = self.store.identify_token(token)
95
+ if name is None:
96
+ raise HubError(ErrorCode.UNAUTHORIZED, "unknown token", 401)
97
+ return name
98
+
99
+ def agent(self, name: str, header: str | None) -> str:
100
+ token = _bearer(header)
101
+ if token is None:
102
+ raise HubError(ErrorCode.UNAUTHORIZED, "bearer token required", 401)
103
+ if self.config.admin_token is not None and token == self.config.admin_token:
104
+ return name
105
+ if not self.store.verify_token(name, token):
106
+ raise HubError(ErrorCode.UNAUTHORIZED, "invalid token for agent", 401)
107
+ self.store.heartbeat(name)
108
+ return name
109
+
110
+
111
+ def _require_room_access(caller: str | None, room) -> None:
112
+ """Open mode (caller is None) reads freely; an authenticated caller must belong."""
113
+ if caller is None or caller == "admin":
114
+ return
115
+ if caller not in room.members:
116
+ raise HubError(ErrorCode.FORBIDDEN, "not a member of this room", 403)
117
+
118
+
119
+ def build_router(store: Store, config: HubConfig) -> APIRouter:
120
+ router = APIRouter(prefix="/api/v1")
121
+ auth = Auth(store, config)
122
+
123
+ def as_agent(name: str, authorization: AuthHeader = None) -> str:
124
+ return auth.agent(name, authorization)
125
+
126
+ # ------------------------------------------------------------- agents
127
+
128
+ @router.post("/agents/register")
129
+ def register(payload: dict = Body(...), authorization: AuthHeader = None) -> dict:
130
+ bearer = _bearer(authorization)
131
+ gate_tokens = {t for t in (config.register_token, config.admin_token) if t}
132
+ privileged = bearer is not None and bearer in gate_tokens
133
+ if config.register_token is not None and not privileged:
134
+ raise HubError(ErrorCode.UNAUTHORIZED, "registration token required", 401)
135
+ # `kind` is not self-declared: a claude-kind agent is exempt from round-robin
136
+ # floor control, so anyone could otherwise register as one and talk without limit.
137
+ kind = AgentKind(payload.get("kind", AgentKind.WORKER))
138
+ if kind is not AgentKind.WORKER and not privileged:
139
+ raise HubError(ErrorCode.FORBIDDEN, "only workers may self-register", 403)
140
+ agent, token = store.register_agent(
141
+ name=payload["name"],
142
+ kind=kind,
143
+ node=payload.get("node"),
144
+ backend=payload.get("backend"),
145
+ tags=payload.get("tags") or [],
146
+ presented_token=None if privileged else bearer,
147
+ privileged=privileged,
148
+ )
149
+ return {"agent": agent.to_dict(), "token": token}
150
+
151
+ @router.post("/agents/{name}/heartbeat")
152
+ def heartbeat(name: str, agent: str = Depends(as_agent)) -> dict:
153
+ return {"ok": True, "agent": name}
154
+
155
+ @router.get("/agents")
156
+ def list_agents(kind: str | None = None, authorization: AuthHeader = None) -> dict:
157
+ auth.reader(authorization)
158
+ agents = store.list_agents(AgentKind(kind) if kind else None)
159
+ return {"agents": [a.to_dict() for a in agents]}
160
+
161
+ @router.get("/agents/{name}/events")
162
+ async def agent_events(
163
+ name: str, request: Request, agent: str = Depends(as_agent)
164
+ ) -> StreamingResponse:
165
+ return StreamingResponse(
166
+ _event_stream(store, name, request),
167
+ media_type="text/event-stream",
168
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
169
+ )
170
+
171
+ # -------------------------------------------------------------- rooms
172
+
173
+ @router.post("/rooms")
174
+ def create_room(payload: dict = Body(...), authorization: AuthHeader = None) -> dict:
175
+ creator = payload["as_agent"]
176
+ auth.agent(creator, authorization)
177
+ room = store.create_room(
178
+ topic=payload.get("topic", "room"),
179
+ created_by=creator,
180
+ participants=payload.get("participants") or [],
181
+ policy=RoomPolicy.from_dict(payload.get("policy")),
182
+ open_room=bool(payload.get("open")),
183
+ )
184
+ return {"room": room.to_dict()}
185
+
186
+ @router.get("/rooms")
187
+ def list_rooms(
188
+ as_agent: str | None = Query(None),
189
+ include_archived: bool = False,
190
+ authorization: AuthHeader = None,
191
+ ) -> dict:
192
+ """Rooms for one agent, or every room when called with the admin token."""
193
+ if as_agent:
194
+ auth.agent(as_agent, authorization)
195
+ else:
196
+ auth.admin(authorization)
197
+ rooms = store.list_rooms(agent=as_agent, include_archived=include_archived)
198
+ return {"rooms": [r.to_dict() for r in rooms]}
199
+
200
+ @router.get("/rooms/{room_id}")
201
+ def get_room(room_id: str, authorization: AuthHeader = None) -> dict:
202
+ caller = auth.reader(authorization)
203
+ room = store.require_room(room_id)
204
+ _require_room_access(caller, room)
205
+ return {"room": room.to_dict()}
206
+
207
+ @router.post("/rooms/{room_id}/join")
208
+ def join_room(
209
+ room_id: str, payload: dict = Body(...), authorization: AuthHeader = None
210
+ ) -> dict:
211
+ who = payload["as_agent"]
212
+ auth.agent(who, authorization)
213
+ privileged = auth.is_privileged(authorization)
214
+ return {"room": store.join_room(room_id, who, privileged=privileged).to_dict()}
215
+
216
+ @router.post("/rooms/{room_id}/leave")
217
+ def leave_room(
218
+ room_id: str, payload: dict = Body(...), authorization: AuthHeader = None
219
+ ) -> dict:
220
+ who = payload["as_agent"]
221
+ auth.agent(who, authorization)
222
+ store.leave_room(room_id, who)
223
+ return {"ok": True}
224
+
225
+ @router.post("/rooms/{room_id}/archive")
226
+ def archive_room(
227
+ room_id: str, payload: dict = Body(default={}), authorization: AuthHeader = None
228
+ ) -> dict:
229
+ who = payload.get("as_agent")
230
+ if who:
231
+ auth.agent(who, authorization)
232
+ else:
233
+ auth.admin(authorization)
234
+ reason = payload.get("reason") or f"archived by {who or 'admin'}"
235
+ privileged = who is None or auth.is_privileged(authorization)
236
+ archived = store.archive_room(room_id, reason, by=who, privileged=privileged)
237
+ return {"room": archived.to_dict()}
238
+
239
+ @router.post("/rooms/{room_id}/messages")
240
+ def post_message(
241
+ room_id: str, payload: dict = Body(...), authorization: AuthHeader = None
242
+ ) -> dict:
243
+ sender = payload["as_agent"]
244
+ auth.agent(sender, authorization)
245
+ message = store.post_message(
246
+ room_id=room_id,
247
+ sender=sender,
248
+ body=payload["body"],
249
+ data=payload.get("data"),
250
+ reply_to=payload.get("reply_to"),
251
+ mentions=payload.get("mentions"),
252
+ client_msg_id=payload.get("client_msg_id"),
253
+ tokens=payload.get("tokens"),
254
+ )
255
+ return {"message": message.to_dict()}
256
+
257
+ @router.post("/rooms/{room_id}/yield")
258
+ def yield_floor(
259
+ room_id: str, payload: dict = Body(...), authorization: AuthHeader = None
260
+ ) -> dict:
261
+ who = payload["as_agent"]
262
+ auth.agent(who, authorization)
263
+ return {"room": store.yield_floor(room_id, who).to_dict()}
264
+
265
+ @router.get("/rooms/{room_id}/messages")
266
+ def get_messages(
267
+ room_id: str,
268
+ after: int = 0,
269
+ limit: int = 100,
270
+ tail: bool = False,
271
+ authorization: AuthHeader = None,
272
+ ) -> dict:
273
+ caller = auth.reader(authorization)
274
+ _require_room_access(caller, store.require_room(room_id))
275
+ messages = store.fetch_messages(room_id, after_seq=after, limit=min(limit, 500), tail=tail)
276
+ return {"messages": [m.to_dict() for m in messages]}
277
+
278
+ @router.get("/inbox")
279
+ def inbox(
280
+ as_agent: str = Query(...),
281
+ after: int = 0,
282
+ limit: int = 100,
283
+ room_id: str | None = None,
284
+ authorization: AuthHeader = None,
285
+ ) -> dict:
286
+ auth.agent(as_agent, authorization)
287
+ messages, cursor = store.fetch_inbox(as_agent, after, min(limit, 500), room_id)
288
+ return {"messages": messages, "cursor": cursor}
289
+
290
+ # -------------------------------------------------------------- tasks
291
+
292
+ @router.post("/tasks")
293
+ def submit_task(payload: dict = Body(...), authorization: AuthHeader = None) -> dict:
294
+ creator = payload["as_agent"]
295
+ auth.agent(creator, authorization)
296
+ task = store.submit_task(
297
+ title=payload["title"],
298
+ spec=payload["spec"],
299
+ created_by=creator,
300
+ assignee=payload.get("assignee"),
301
+ selector=payload.get("selector"),
302
+ priority=int(payload.get("priority", 0)),
303
+ timeout_s=float(payload.get("timeout_s", DEFAULT_TASK_TIMEOUT_S)),
304
+ dedupe_key=payload.get("dedupe_key"),
305
+ )
306
+ return {"task": task.to_dict()}
307
+
308
+ @router.get("/tasks")
309
+ def list_tasks(
310
+ state: str | None = None,
311
+ assignee: str | None = None,
312
+ created_by: str | None = None,
313
+ limit: int = 100,
314
+ authorization: AuthHeader = None,
315
+ ) -> dict:
316
+ caller = auth.reader(authorization)
317
+ if caller is not None and caller != "admin" and not (assignee or created_by):
318
+ # A plain agent sees its own work, not the whole fleet's task specs.
319
+ own = store.list_tasks(state=TaskState(state) if state else None, assignee=caller)
320
+ mine = store.list_tasks(state=TaskState(state) if state else None, created_by=caller)
321
+ merged = {t.id: t for t in [*own, *mine]}
322
+ return {"tasks": [t.to_dict() for t in merged.values()][:limit]}
323
+ tasks = store.list_tasks(
324
+ state=TaskState(state) if state else None,
325
+ assignee=assignee,
326
+ created_by=created_by,
327
+ limit=limit,
328
+ )
329
+ return {"tasks": [t.to_dict() for t in tasks]}
330
+
331
+ @router.get("/tasks/next")
332
+ def next_task(as_agent: str = Query(...), authorization: AuthHeader = None) -> dict:
333
+ auth.agent(as_agent, authorization)
334
+ task = store.next_task_for(as_agent)
335
+ return {"task": task.to_dict() if task else None}
336
+
337
+ @router.get("/tasks/{task_id}")
338
+ def get_task(task_id: str, authorization: AuthHeader = None) -> dict:
339
+ caller = auth.reader(authorization)
340
+ task = store.require_task(task_id)
341
+ if (
342
+ caller is not None
343
+ and caller != "admin"
344
+ and caller
345
+ not in (
346
+ task.created_by,
347
+ task.assignee,
348
+ )
349
+ ):
350
+ raise HubError(ErrorCode.FORBIDDEN, "not your task", 403)
351
+ return {"task": task.to_dict(), "events": [e.to_dict() for e in store.task_events(task_id)]}
352
+
353
+ @router.post("/tasks/{task_id}/claim")
354
+ def claim_task(
355
+ task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
356
+ ) -> dict:
357
+ who = payload["as_agent"]
358
+ auth.agent(who, authorization)
359
+ return {"task": store.claim_task(task_id, who).to_dict()}
360
+
361
+ @router.post("/tasks/{task_id}/progress")
362
+ def task_progress(
363
+ task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
364
+ ) -> dict:
365
+ who = payload["as_agent"]
366
+ auth.agent(who, authorization)
367
+ task = store.update_progress(task_id, who, payload.get("pct"), payload.get("message"))
368
+ return {"task": task.to_dict()}
369
+
370
+ @router.post("/tasks/{task_id}/result")
371
+ def task_result(
372
+ task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
373
+ ) -> dict:
374
+ who = payload["as_agent"]
375
+ auth.agent(who, authorization)
376
+ return {"task": store.complete_task(task_id, who, payload.get("result")).to_dict()}
377
+
378
+ @router.post("/tasks/{task_id}/fail")
379
+ def task_fail(
380
+ task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
381
+ ) -> dict:
382
+ who = payload["as_agent"]
383
+ auth.agent(who, authorization)
384
+ return {
385
+ "task": store.fail_task(
386
+ task_id, who, payload.get("error", "unknown"), result=payload.get("result")
387
+ ).to_dict()
388
+ }
389
+
390
+ @router.post("/tasks/{task_id}/input_required")
391
+ def task_input_required(
392
+ task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
393
+ ) -> dict:
394
+ who = payload["as_agent"]
395
+ auth.agent(who, authorization)
396
+ return {"task": store.request_input(task_id, who, payload["question"]).to_dict()}
397
+
398
+ @router.post("/tasks/{task_id}/input")
399
+ def task_input(
400
+ task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
401
+ ) -> dict:
402
+ who = payload["as_agent"]
403
+ auth.agent(who, authorization)
404
+ return {"task": store.provide_input(task_id, who, payload["message"]).to_dict()}
405
+
406
+ @router.post("/tasks/{task_id}/cancel")
407
+ def task_cancel(
408
+ task_id: str, payload: dict = Body(default={}), authorization: AuthHeader = None
409
+ ) -> dict:
410
+ who = payload.get("as_agent")
411
+ if who:
412
+ auth.agent(who, authorization)
413
+ else:
414
+ auth.admin(authorization)
415
+ privileged = who is None or auth.is_privileged(authorization)
416
+ cancelled = store.cancel_task(task_id, who or "admin", privileged=privileged)
417
+ return {"task": cancelled.to_dict()}
418
+
419
+ # ---------------------------------------------------------- artifacts
420
+
421
+ @router.post("/artifacts")
422
+ def upload_artifact(payload: dict = Body(...), authorization: AuthHeader = None) -> dict:
423
+ who = payload["as_agent"]
424
+ auth.agent(who, authorization)
425
+ import base64
426
+
427
+ content = base64.b64decode(payload["content_b64"])
428
+ meta = store.save_artifact(
429
+ name=payload.get("name", "artifact"),
430
+ mime=payload.get("mime", "application/octet-stream"),
431
+ content=content,
432
+ created_by=who,
433
+ )
434
+ return {"artifact": meta}
435
+
436
+ @router.get("/artifacts/{artifact_id}")
437
+ def download_artifact(artifact_id: str, authorization: AuthHeader = None) -> dict:
438
+ auth.reader(authorization)
439
+ import base64
440
+
441
+ meta, content = store.get_artifact(artifact_id)
442
+ return {"artifact": meta, "content_b64": base64.b64encode(content).decode()}
443
+
444
+ # -------------------------------------------------------------- watch
445
+
446
+ @router.get("/watch")
447
+ async def watch(
448
+ request: Request, identity: str = Query(...), authorization: AuthHeader = None
449
+ ) -> StreamingResponse:
450
+ auth.admin(authorization)
451
+ store.ensure_identity(identity)
452
+ return StreamingResponse(
453
+ _event_stream(store, identity, request),
454
+ media_type="text/event-stream",
455
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
456
+ )
457
+
458
+ return router
459
+
460
+
461
+ async def _event_stream(store: Store, agent: str, request: Request) -> AsyncIterator[str]:
462
+ """SSE stream. The open connection doubles as the agent's heartbeat."""
463
+ async with store.bus.subscribe(agent) as queue:
464
+ store.heartbeat(agent)
465
+ yield _sse({"kind": "connected", "agent": agent})
466
+ while True:
467
+ if await request.is_disconnected():
468
+ break
469
+ try:
470
+ event = await asyncio.wait_for(queue.get(), timeout=SSE_KEEPALIVE_S)
471
+ except TimeoutError:
472
+ store.heartbeat(agent)
473
+ yield ": keepalive\n\n"
474
+ continue
475
+ store.heartbeat(agent)
476
+ yield _sse(event)
477
+
478
+
479
+ def _sse(payload: dict) -> str:
480
+ return f"data: {json.dumps(payload)}\n\n"
481
+
482
+
483
+ def create_app(store: Store, config: HubConfig, mcp_app: Any | None = None) -> FastAPI:
484
+ @contextlib.asynccontextmanager
485
+ async def lifespan(app: FastAPI):
486
+ store.bus.bind(asyncio.get_running_loop())
487
+ sweeper = asyncio.create_task(_sweep_loop(store, config.sweep_interval_s))
488
+ try:
489
+ if mcp_app is not None and hasattr(mcp_app, "lifespan"):
490
+ async with mcp_app.lifespan(app):
491
+ yield
492
+ else:
493
+ yield
494
+ finally:
495
+ sweeper.cancel()
496
+ with contextlib.suppress(asyncio.CancelledError):
497
+ await sweeper
498
+
499
+ app = FastAPI(title="yeschef hub", version="0.1.0", lifespan=lifespan)
500
+
501
+ @app.exception_handler(HubError)
502
+ async def hub_error_handler(request: Request, exc: HubError) -> JSONResponse:
503
+ return JSONResponse(status_code=exc.http_status, content=exc.to_dict())
504
+
505
+ @app.get("/healthz")
506
+ def healthz() -> dict:
507
+ agents = store.list_agents()
508
+ return {
509
+ "ok": True,
510
+ "agents": len(agents),
511
+ "online": sum(1 for a in agents if str(a.status()) == "online"),
512
+ "queued_tasks": len(store.list_tasks(state=TaskState.QUEUED)),
513
+ "lifetime": store.lifetime_stats(),
514
+ }
515
+
516
+ @app.get("/overview")
517
+ def overview() -> dict:
518
+ """Read-only fleet snapshot for the dashboard — curated to omit task specs and
519
+ result bodies, so it is safe to serve un-authenticated within the same
520
+ LAN/Tailscale boundary as /healthz (never expose the hub through a funnel)."""
521
+ ref = now()
522
+ agents = store.list_agents()
523
+ active: dict[str, int] = {}
524
+ for t in store.list_tasks(limit=500):
525
+ if t.state in (TaskState.CLAIMED, TaskState.WORKING, TaskState.INPUT_REQUIRED):
526
+ if t.assignee:
527
+ active[t.assignee] = active.get(t.assignee, 0) + 1
528
+ workers = []
529
+ for a in agents:
530
+ if a.kind is not AgentKind.WORKER:
531
+ continue
532
+ d = a.to_dict(ref)
533
+ d["active_tasks"] = active.get(a.name, 0)
534
+ d["heartbeat_age_s"] = round(ref - a.last_seen, 1)
535
+ workers.append(d)
536
+ workers.sort(key=lambda w: (w["status"] != "online", w["name"]))
537
+
538
+ def task_row(t) -> dict:
539
+ return {
540
+ "id": t.id,
541
+ "title": t.title,
542
+ "state": str(t.state),
543
+ "assignee": t.assignee,
544
+ "selector": t.selector,
545
+ "progress": {"pct": t.progress_pct, "message": t.progress_msg},
546
+ "attempts": t.attempts,
547
+ "error": t.error,
548
+ "age_s": round(ref - t.created_at, 1) if t.created_at else None,
549
+ "ran_s": (
550
+ round(t.finished_at - t.claimed_at, 1)
551
+ if t.finished_at and t.claimed_at
552
+ else None
553
+ ),
554
+ "files": len((t.result or {}).get("files") or []),
555
+ }
556
+
557
+ tasks = [task_row(t) for t in store.list_tasks(limit=60)]
558
+ rooms = [
559
+ {
560
+ "id": r.id,
561
+ "topic": r.topic,
562
+ "members": [m for m in r.members if not m.startswith(("claude:", "operator:"))],
563
+ "is_dm": r.dm_key is not None,
564
+ "archived": r.archived,
565
+ "archived_reason": r.archived_reason,
566
+ }
567
+ for r in store.list_rooms(include_archived=True)[:20]
568
+ ]
569
+ return {
570
+ "now": ref,
571
+ "workers": workers,
572
+ "sessions": [a.to_dict(ref) for a in agents if a.kind is AgentKind.CLAUDE],
573
+ "tasks": tasks,
574
+ "rooms": rooms,
575
+ "lifetime": store.lifetime_stats(),
576
+ }
577
+
578
+ @app.get("/", response_class=HTMLResponse)
579
+ def dashboard() -> str:
580
+ return (Path(__file__).with_name("dashboard.html")).read_text()
581
+
582
+ app.include_router(build_router(store, config))
583
+ if mcp_app is not None:
584
+ app.mount("/mcp", mcp_app)
585
+ return app
586
+
587
+
588
+ async def _sweep_loop(store: Store, interval: float) -> None:
589
+ while True:
590
+ await asyncio.sleep(interval)
591
+ with contextlib.suppress(Exception):
592
+ await asyncio.to_thread(store.sweep)
593
+
594
+
595
+ __all__ = ["HubConfig", "create_app", "build_router", "MAX_LONG_POLL_S"]
yeschef/hub/app.py ADDED
@@ -0,0 +1,43 @@
1
+ """Assemble the hub: one ASGI app serving /mcp for Claude Code and /api/v1 for agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI
8
+
9
+ from .api import HubConfig, create_app
10
+ from .events import EventBus
11
+ from .store import Store
12
+
13
+
14
+ def config_from_env() -> HubConfig:
15
+ from ..settings import env, home
16
+
17
+ return HubConfig(
18
+ db_path=env("DB", str(home() / "hub.db")),
19
+ admin_token=env("ADMIN_TOKEN"),
20
+ register_token=env("REGISTER_TOKEN"),
21
+ default_identity=env("IDENTITY", "claude:local"),
22
+ )
23
+
24
+
25
+ def build_hub(config: HubConfig | None = None) -> tuple[FastAPI, Store]:
26
+ config = config or config_from_env()
27
+ db_path = (
28
+ config.db_path if config.db_path == ":memory:" else str(Path(config.db_path).expanduser())
29
+ )
30
+ store = Store(db_path, EventBus())
31
+
32
+ from .mcp_server import build_mcp
33
+
34
+ mcp = build_mcp(store, config)
35
+ mcp_app = mcp.http_app(path="/")
36
+ app = create_app(store, config, mcp_app=mcp_app)
37
+ return app, store
38
+
39
+
40
+ def app_factory() -> FastAPI:
41
+ """Entry point for `uvicorn yeschef.hub.app:app_factory --factory`."""
42
+ app, _ = build_hub()
43
+ return app