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,206 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>yeschef</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f6f7f9; --panel: #ffffff; --line: #e3e6ea; --ink: #1b2027;
10
+ --dim: #6b7280; --accent: #2f7d55; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
11
+ --online: #2f7d55; --working: #b7791f; --queued: #6b7280; --failed: #c0392b;
12
+ --done: #2f7d55; --cancel: #8a8f98; --input: #6d5bd0;
13
+ }
14
+ @media (prefers-color-scheme: dark) {
15
+ :root:not([data-theme="light"]) {
16
+ --bg: #0d1117; --panel: #161b22; --line: #262c36; --ink: #e6edf3;
17
+ --dim: #8b949e; --accent: #3fb950;
18
+ --online: #3fb950; --working: #d29922; --queued: #8b949e; --failed: #f85149;
19
+ --done: #3fb950; --cancel: #6e7681; --input: #a371f7;
20
+ }
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body {
24
+ margin: 0; background: var(--bg); color: var(--ink);
25
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
26
+ font-size: 14px; line-height: 1.5;
27
+ }
28
+ header {
29
+ display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap;
30
+ padding: 18px 24px; border-bottom: 1px solid var(--line);
31
+ }
32
+ header h1 { margin: 0; font-size: 19px; letter-spacing: -0.01em; }
33
+ header h1 .ball { color: var(--accent); }
34
+ .fleet { color: var(--dim); font-size: 13px; }
35
+ .fleet b { color: var(--ink); }
36
+ .updated { margin-left: auto; color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
37
+ main { padding: 20px 24px; max-width: 1100px; margin: 0 auto; }
38
+ .stats { display: flex; gap: 28px; flex-wrap: wrap; margin-bottom: 8px; }
39
+ .stat .n { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }
40
+ .stat .l { color: var(--dim); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
41
+ .caveat { color: var(--dim); font-size: 12px; margin: 4px 0 22px; max-width: 70ch; }
42
+ section { margin-bottom: 26px; }
43
+ h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--dim);
44
+ margin: 0 0 8px; font-weight: 600; }
45
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
46
+ table { width: 100%; border-collapse: collapse; }
47
+ th, td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--line); }
48
+ th { color: var(--dim); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
49
+ tr:last-child td { border-bottom: none; }
50
+ td.mono, .mono { font-family: var(--mono); font-size: 12.5px; }
51
+ .tabnum { font-variant-numeric: tabular-nums; }
52
+ .pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px;
53
+ padding: 2px 9px; border-radius: 999px; border: 1px solid transparent; white-space: nowrap; }
54
+ .pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
55
+ .s-online, .s-completed { color: var(--done); background: color-mix(in srgb, var(--done) 12%, transparent); }
56
+ .s-working, .s-claimed { color: var(--working); background: color-mix(in srgb, var(--working) 14%, transparent); }
57
+ .s-queued { color: var(--queued); background: color-mix(in srgb, var(--queued) 14%, transparent); }
58
+ .s-failed { color: var(--failed); background: color-mix(in srgb, var(--failed) 13%, transparent); }
59
+ .s-cancelled { color: var(--cancel); background: color-mix(in srgb, var(--cancel) 14%, transparent); }
60
+ .s-input_required { color: var(--input); background: color-mix(in srgb, var(--input) 14%, transparent); }
61
+ .s-offline { color: var(--dim); background: color-mix(in srgb, var(--dim) 12%, transparent); }
62
+ .bar { height: 4px; background: var(--line); border-radius: 3px; overflow: hidden; min-width: 60px; }
63
+ .bar > i { display: block; height: 100%; background: var(--working); }
64
+ .empty { padding: 26px 14px; text-align: center; color: var(--dim); }
65
+ .empty code { font-family: var(--mono); background: color-mix(in srgb, var(--dim) 14%, transparent);
66
+ padding: 1px 6px; border-radius: 5px; }
67
+ .muted { color: var(--dim); }
68
+ .flag { font-size: 11px; color: var(--failed); font-family: var(--mono); }
69
+ .wrap { overflow-x: auto; }
70
+ .offline-banner { background: color-mix(in srgb, var(--failed) 12%, transparent);
71
+ border: 1px solid color-mix(in srgb, var(--failed) 30%, transparent);
72
+ color: var(--failed); border-radius: 10px; padding: 12px 16px; margin-bottom: 20px; }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <header>
77
+ <h1><span class="ball">🍳</span> yeschef</h1>
78
+ <span class="fleet" id="fleet">connecting…</span>
79
+ <span class="updated" id="updated"></span>
80
+ </header>
81
+ <main id="app">
82
+ <div class="empty">loading…</div>
83
+ </main>
84
+
85
+ <script>
86
+ const $ = (h) => { const t = document.createElement("template"); t.innerHTML = h.trim(); return t.content.firstChild; };
87
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
88
+ const ago = (s) => {
89
+ if (s == null) return "—";
90
+ if (s < 60) return Math.round(s) + "s";
91
+ if (s < 3600) return Math.round(s/60) + "m";
92
+ return (s/3600).toFixed(1) + "h";
93
+ };
94
+ const clock = (sec) => sec >= 3600 ? (sec/3600).toFixed(1)+"h" : Math.round(sec/60)+"m";
95
+
96
+ function statePill(state) {
97
+ return `<span class="pill s-${esc(state)}">${esc(state)}</span>`;
98
+ }
99
+
100
+ function render(d) {
101
+ const online = d.workers.filter(w => w.status === "online").length;
102
+ document.getElementById("fleet").innerHTML =
103
+ `<b>${online}</b>/${d.workers.length} cooks on · <b>${d.lifetime.tasks_completed ?? 0}</b> tickets cooked lifetime`;
104
+ document.getElementById("updated").textContent = "updated " + new Date().toLocaleTimeString();
105
+
106
+ const lt = d.lifetime;
107
+ const failTail = (lt.tasks_failed_cancelled ?? 0) > 0
108
+ ? ` <span class="muted">(+${lt.tasks_failed_cancelled} failed/cancelled, ${clock(lt.work_seconds_failed || 0)})</span>` : "";
109
+ const taskTokens = lt.task_tokens ?? lt.local_tokens ?? 0;
110
+
111
+ const app = document.getElementById("app");
112
+ app.innerHTML = "";
113
+
114
+ if (online === 0) {
115
+ app.appendChild($(`<div class="offline-banner">No cooks are on the line. Clock one in with
116
+ <code style="font-family:var(--mono)">yeschef join</code> on a machine with a local model server.</div>`));
117
+ }
118
+
119
+ // lifetime stats
120
+ app.appendChild($(`<div class="stats">
121
+ <div class="stat"><div class="n tabnum">${lt.tasks_completed ?? 0}</div><div class="l">tickets cooked${failTail}</div></div>
122
+ <div class="stat"><div class="n tabnum">${(taskTokens).toLocaleString()}</div><div class="l">tokens cooked locally</div></div>
123
+ <div class="stat"><div class="n tabnum">${clock(lt.work_seconds || 0)}</div><div class="l">local compute</div></div>
124
+ </div>`));
125
+ app.appendChild($(`<p class="caveat">Locally-generated tokens are compute run on your hardware, not saved
126
+ Claude tokens one-for-one — local models are weaker and their output usually needs review.</p>`));
127
+
128
+ // cooks
129
+ const wsec = $(`<section><h2>The line</h2></section>`);
130
+ if (!d.workers.length) {
131
+ wsec.appendChild($(`<div class="card"><div class="empty">No cooks clocked in. Run <code>yeschef join</code>.</div></div>`));
132
+ } else {
133
+ const rows = d.workers.map(w => {
134
+ const busy = w.active_tasks ? `<span class="pill s-working">${w.active_tasks} active</span>` : `<span class="muted">idle</span>`;
135
+ const stale = w.status !== "online" ? `<span class="muted"> · ${ago(w.heartbeat_age_s)} ago</span>` : "";
136
+ return `<tr>
137
+ <td class="mono">${esc(w.name)}</td>
138
+ <td class="mono">${esc(w.backend || "—")}</td>
139
+ <td class="mono muted">${esc(w.node || "—")}</td>
140
+ <td>${statePill(w.status)}${stale}</td>
141
+ <td>${busy}</td>
142
+ </tr>`;
143
+ }).join("");
144
+ wsec.appendChild($(`<div class="card wrap"><table>
145
+ <thead><tr><th>cook</th><th>model</th><th>station</th><th>status</th><th>load</th></tr></thead>
146
+ <tbody>${rows}</tbody></table></div>`));
147
+ }
148
+ app.appendChild(wsec);
149
+
150
+ // tickets
151
+ const tsec = $(`<section><h2>The rail</h2></section>`);
152
+ if (!d.tasks.length) {
153
+ tsec.appendChild($(`<div class="card"><div class="empty">No tickets yet. Fire one from Claude Code, or <code>yeschef submit</code>.</div></div>`));
154
+ } else {
155
+ const rows = d.tasks.map(t => {
156
+ const live = t.state === "working" || t.state === "claimed";
157
+ const pct = t.progress && t.progress.pct != null ? t.progress.pct : (t.state === "completed" ? 100 : 0);
158
+ const prog = live
159
+ ? `<div class="bar" title="${esc(t.progress?.message || "")}"><i style="width:${pct}%"></i></div>`
160
+ : (t.state === "completed" && t.files ? `<span class="muted">${t.files} file${t.files>1?"s":""}</span>`
161
+ : t.error ? `<span class="flag">${esc(t.error.slice(0,40))}</span>` : `<span class="muted">—</span>`);
162
+ const dur = t.ran_s != null ? ago(t.ran_s) : ago(t.age_s);
163
+ return `<tr>
164
+ <td>${statePill(t.state)}</td>
165
+ <td>${esc(t.title || t.id)}</td>
166
+ <td class="mono muted">${esc(t.assignee || t.selector || "—")}</td>
167
+ <td class="tabnum muted">${dur}</td>
168
+ <td style="min-width:120px">${prog}</td>
169
+ </tr>`;
170
+ }).join("");
171
+ tsec.appendChild($(`<div class="card wrap"><table>
172
+ <thead><tr><th>state</th><th>ticket</th><th>cook</th><th>age</th><th>progress</th></tr></thead>
173
+ <tbody>${rows}</tbody></table></div>`));
174
+ }
175
+ app.appendChild(tsec);
176
+
177
+ // rooms
178
+ const active = d.rooms.filter(r => !r.archived);
179
+ if (d.rooms.length) {
180
+ const rsec = $(`<section><h2>Talking it over ${active.length ? `· ${active.length} live` : ""}</h2></section>`);
181
+ const rows = d.rooms.slice(0, 12).map(r => `<tr>
182
+ <td>${esc(r.topic)}</td>
183
+ <td class="mono muted">${esc((r.members || []).join(", ") || "—")}</td>
184
+ <td>${r.archived ? `<span class="pill s-cancelled">${esc(r.archived_reason || "closed")}</span>` : `<span class="pill s-working">open</span>`}</td>
185
+ </tr>`).join("");
186
+ rsec.appendChild($(`<div class="card wrap"><table>
187
+ <thead><tr><th>topic</th><th>participants</th><th>state</th></tr></thead>
188
+ <tbody>${rows}</tbody></table></div>`));
189
+ app.appendChild(rsec);
190
+ }
191
+ }
192
+
193
+ async function tick() {
194
+ try {
195
+ const r = await fetch("/overview", { cache: "no-store" });
196
+ if (!r.ok) throw new Error(r.status);
197
+ render(await r.json());
198
+ } catch (e) {
199
+ document.getElementById("fleet").innerHTML = `<span style="color:var(--failed)">hub unreachable</span>`;
200
+ }
201
+ }
202
+ tick();
203
+ setInterval(tick, 2500);
204
+ </script>
205
+ </body>
206
+ </html>
yeschef/hub/events.py ADDED
@@ -0,0 +1,78 @@
1
+ """In-process fan-out of hub events to connected agents.
2
+
3
+ Each connected agent holds one queue per open stream. The store is synchronous, so it
4
+ publishes through a thread-safe hand-off onto the event loop.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ from collections import defaultdict
12
+ from collections.abc import AsyncIterator
13
+
14
+ from ..models import EventKind, now
15
+
16
+ MAX_QUEUE = 256
17
+ MAX_PENDING = 1024
18
+ """Cap on events buffered before the bus is bound to a loop (CLI and embedded use)."""
19
+
20
+
21
+ class EventBus:
22
+ def __init__(self) -> None:
23
+ self._subs: dict[str, set[asyncio.Queue]] = defaultdict(set)
24
+ self._loop: asyncio.AbstractEventLoop | None = None
25
+ self._pending: list[tuple[str, dict]] = []
26
+
27
+ def bind(self, loop: asyncio.AbstractEventLoop) -> None:
28
+ """Attach to the serving loop; flush anything published before startup."""
29
+ self._loop = loop
30
+ pending, self._pending = self._pending, []
31
+ for target, event in pending:
32
+ self._deliver(target, event)
33
+
34
+ def publish(self, target: str, kind: EventKind | str, payload: dict) -> None:
35
+ """Publish to one agent. Safe from any thread."""
36
+ event = {"kind": str(kind), "at": now(), **payload}
37
+ loop = self._loop
38
+ if loop is None:
39
+ # Nobody is listening yet (CLI, embedding, tests). Keep a bounded tail so a
40
+ # long-lived unbound Store cannot leak every event it ever published.
41
+ self._pending.append((target, event))
42
+ del self._pending[:-MAX_PENDING]
43
+ return
44
+ try:
45
+ running = asyncio.get_running_loop()
46
+ except RuntimeError:
47
+ running = None
48
+ if running is loop:
49
+ self._deliver(target, event)
50
+ else:
51
+ loop.call_soon_threadsafe(self._deliver, target, event)
52
+
53
+ def publish_many(self, targets: list[str], kind: EventKind | str, payload: dict) -> None:
54
+ for target in targets:
55
+ self.publish(target, kind, payload)
56
+
57
+ def _deliver(self, target: str, event: dict) -> None:
58
+ for queue in list(self._subs.get(target, ())):
59
+ if queue.full():
60
+ # Slow consumer: drop the oldest so a stalled stream cannot wedge the hub.
61
+ with contextlib.suppress(asyncio.QueueEmpty):
62
+ queue.get_nowait()
63
+ with contextlib.suppress(asyncio.QueueFull):
64
+ queue.put_nowait(event)
65
+
66
+ def subscriber_count(self, agent: str) -> int:
67
+ return len(self._subs.get(agent, ()))
68
+
69
+ @contextlib.asynccontextmanager
70
+ async def subscribe(self, agent: str) -> AsyncIterator[asyncio.Queue]:
71
+ queue: asyncio.Queue = asyncio.Queue(maxsize=MAX_QUEUE)
72
+ self._subs[agent].add(queue)
73
+ try:
74
+ yield queue
75
+ finally:
76
+ self._subs[agent].discard(queue)
77
+ if not self._subs[agent]:
78
+ self._subs.pop(agent, None)