@dorsk/yubisashi 0.1.0

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.
package/shell/app.js ADDED
@@ -0,0 +1,554 @@
1
+ const $ = (id) => document.getElementById(id);
2
+ const frame = $('app');
3
+ const overlay = $('overlay');
4
+ const input = $('input');
5
+
6
+ const hash = new URLSearchParams(location.hash.slice(1));
7
+ const token = hash.get('token') ?? '';
8
+
9
+ const state = {
10
+ annotations: [],
11
+ transcript: [],
12
+ session: null,
13
+ picking: false,
14
+ hover: null,
15
+ hoverLabel: '',
16
+ /** @type {{ el: Element, info: any }[]} */
17
+ selection: [],
18
+ thread: null,
19
+ tab: 'chat',
20
+ /** annotation id → elements picked in this tab, so pins survive re-renders that change selectors */
21
+ refs: new Map(),
22
+ };
23
+
24
+ async function api(path, body) {
25
+ const res = await fetch(`api${path}`, {
26
+ method: body ? 'POST' : 'GET',
27
+ headers: { 'content-type': 'application/json', 'x-yubi-token': token },
28
+ body: body ? JSON.stringify(body) : undefined,
29
+ });
30
+ if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
31
+ return res.json();
32
+ }
33
+
34
+ const esc = (s) =>
35
+ String(s).replace(
36
+ /[&<>"']/g,
37
+ (c) => `&${{ '&': 'amp', '<': 'lt', '>': 'gt', '"': 'quot', "'": '#39' }[c]};`,
38
+ );
39
+
40
+ function markdown(text) {
41
+ const blocks = text.split(/```[\w-]*\n?/);
42
+ return blocks
43
+ .map((block, i) => {
44
+ if (i % 2) return `<pre><code>${esc(block.replace(/\n$/, ''))}</code></pre>`;
45
+ return block
46
+ .trim()
47
+ .split(/\n{2,}/)
48
+ .filter(Boolean)
49
+ .map((p) => {
50
+ const html = esc(p)
51
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
52
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
53
+ .replace(/^#{1,6} (.*)$/gm, '<strong>$1</strong>')
54
+ .replace(
55
+ /\[([^\]]+)\]\((https?:[^)\s]+)\)/g,
56
+ '<a href="$2" target="_blank" rel="noreferrer">$1</a>',
57
+ )
58
+ .replace(/\n/g, '<br>');
59
+ return `<p>${html}</p>`;
60
+ })
61
+ .join('');
62
+ })
63
+ .join('');
64
+ }
65
+
66
+ // ---------------------------------------------------------------- element capture
67
+
68
+ const shortFile = (file) => (file ? file.split('/').at(-1) : '');
69
+ const where = (loc) =>
70
+ loc?.file ? `${loc.file}:${loc.line}${loc.column ? `:${loc.column}` : ''}` : '';
71
+
72
+ function svelteMeta(el) {
73
+ for (let node = el; node; node = node.parentElement) {
74
+ if (node.__svelte_meta) return node.__svelte_meta;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ function stackOf(meta) {
80
+ const frames = [];
81
+ for (let p = meta?.parent; p && frames.length < 12; p = p.parent) {
82
+ if (p.type !== 'component' || p.file?.includes('.svelte-kit/generated')) continue;
83
+ frames.push({
84
+ type: p.type,
85
+ name: p.componentTag ? `<${p.componentTag}>` : undefined,
86
+ file: p.file,
87
+ line: p.line,
88
+ column: p.column,
89
+ });
90
+ }
91
+ return frames;
92
+ }
93
+
94
+ const SCOPED_CLASS = /^(svelte|s)-[\w-]{5,}$/;
95
+
96
+ function selectorOf(el) {
97
+ const doc = el.ownerDocument;
98
+ const parts = [];
99
+ for (
100
+ let node = el;
101
+ node && node.nodeType === 1 && node !== doc.documentElement;
102
+ node = node.parentElement
103
+ ) {
104
+ for (const attr of ['data-testid', 'data-verb', 'data-test']) {
105
+ const value = node.getAttribute(attr);
106
+ if (value) {
107
+ parts.unshift(`[${attr}="${CSS.escape(value)}"]`);
108
+ return parts.join(' > ');
109
+ }
110
+ }
111
+ if (node.id && !/\d{3,}/.test(node.id)) {
112
+ parts.unshift(`#${CSS.escape(node.id)}`);
113
+ return parts.join(' > ');
114
+ }
115
+ let part = node.localName;
116
+ const classes = [...node.classList].filter((c) => !SCOPED_CLASS.test(c)).slice(0, 2);
117
+ if (classes.length) part += classes.map((c) => `.${CSS.escape(c)}`).join('');
118
+ const siblings = node.parentElement
119
+ ? [...node.parentElement.children].filter((s) => s.localName === node.localName)
120
+ : [];
121
+ if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
122
+ parts.unshift(part);
123
+ if (parts.length >= 6) break;
124
+ }
125
+ return parts.join(' > ');
126
+ }
127
+
128
+ function describe(el) {
129
+ const meta = svelteMeta(el);
130
+ const attrs = {};
131
+ for (const a of el.attributes) {
132
+ if (
133
+ a.name.startsWith('data-') ||
134
+ ['role', 'aria-label', 'href', 'name', 'type', 'title', 'alt'].includes(a.name)
135
+ ) {
136
+ attrs[a.name] = a.value.slice(0, 200);
137
+ }
138
+ }
139
+ const r = el.getBoundingClientRect();
140
+ const html = el.outerHTML.replace(/\s+/g, ' ');
141
+ return {
142
+ tag: el.localName,
143
+ selector: selectorOf(el),
144
+ text: (el.innerText ?? el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 100),
145
+ html: html.length > 500 ? `${html.slice(0, 500)}…` : html,
146
+ attrs,
147
+ rect: {
148
+ x: Math.round(r.x),
149
+ y: Math.round(r.y),
150
+ width: Math.round(r.width),
151
+ height: Math.round(r.height),
152
+ },
153
+ source: meta
154
+ ? { file: meta.loc.file, line: meta.loc.line, column: meta.loc.column }
155
+ : undefined,
156
+ stack: stackOf(meta),
157
+ };
158
+ }
159
+
160
+ const label = (info) => {
161
+ const src = info.source ? `${shortFile(info.source.file)}:${info.source.line}` : info.selector;
162
+ return `<${info.tag}> ${src}`;
163
+ };
164
+
165
+ // ---------------------------------------------------------------- iframe wiring
166
+
167
+ function currentRoute() {
168
+ try {
169
+ const l = frame.contentWindow.location;
170
+ return l.pathname + l.search + l.hash;
171
+ } catch {
172
+ return '/';
173
+ }
174
+ }
175
+
176
+ function setPicking(on) {
177
+ state.picking = on;
178
+ $('pick').classList.toggle('on', on);
179
+ glass.hidden = !on;
180
+ if (!on) state.hover = null;
181
+ }
182
+
183
+ function onKey(e) {
184
+ const typing = e.target.closest?.(
185
+ 'input, textarea, [contenteditable=""], [contenteditable="true"]',
186
+ );
187
+ if (e.key === 'Escape') {
188
+ if (state.picking) setPicking(false);
189
+ else if (state.selection.length) setSelection([]);
190
+ else if (state.thread) setThread(null);
191
+ return;
192
+ }
193
+ if (!typing && (e.key === 'c' || e.key === 'C') && !e.metaKey && !e.ctrlKey && !e.altKey) {
194
+ e.preventDefault();
195
+ setPicking(!state.picking);
196
+ }
197
+ }
198
+
199
+ /** Pick mode covers the app with a glass pane: disabled controls never receive clicks, and the
200
+ * app must not react to the pointing either. */
201
+ const glass = $('glass');
202
+
203
+ function elementAt(e) {
204
+ const doc = frame.contentDocument;
205
+ const r = frame.getBoundingClientRect();
206
+ return doc?.elementFromPoint(e.clientX - r.left, e.clientY - r.top) ?? null;
207
+ }
208
+
209
+ glass.addEventListener('mousemove', (e) => {
210
+ const el = elementAt(e);
211
+ if (el === state.hover) return;
212
+ state.hover = el;
213
+ state.hoverLabel = el ? label(describe(el)) : '';
214
+ });
215
+
216
+ glass.addEventListener('click', (e) => {
217
+ const el = elementAt(e);
218
+ if (!el) return;
219
+ const entry = { el, info: describe(el) };
220
+ if (e.shiftKey || e.metaKey || e.ctrlKey) {
221
+ const i = state.selection.findIndex((s) => s.el === el);
222
+ setSelection(i >= 0 ? state.selection.filter((_, j) => j !== i) : [...state.selection, entry]);
223
+ } else {
224
+ setSelection([entry]);
225
+ setPicking(false);
226
+ }
227
+ setThread(null);
228
+ input.focus();
229
+ });
230
+
231
+ glass.addEventListener(
232
+ 'wheel',
233
+ (e) => {
234
+ const win = frame.contentWindow;
235
+ for (let node = elementAt(e); node; node = node.parentElement) {
236
+ const style = win.getComputedStyle(node);
237
+ if (/(auto|scroll)/.test(style.overflowY + style.overflowX)) {
238
+ const before = node.scrollTop + node.scrollLeft;
239
+ node.scrollBy(e.deltaX, e.deltaY);
240
+ if (node.scrollTop + node.scrollLeft !== before) return;
241
+ }
242
+ }
243
+ win.scrollBy(e.deltaX, e.deltaY);
244
+ },
245
+ { passive: true },
246
+ );
247
+
248
+ function hook() {
249
+ const win = frame.contentWindow;
250
+ if (!win || win.__yubi) return;
251
+ win.__yubi = true;
252
+ win.addEventListener('keydown', onKey, true);
253
+ }
254
+
255
+ frame.addEventListener('load', hook);
256
+ setInterval(() => {
257
+ hook();
258
+ const route = currentRoute();
259
+ if ($('route').textContent !== route) {
260
+ $('route').textContent = route;
261
+ hash.set('route', route);
262
+ history.replaceState(null, '', `#${hash}`);
263
+ }
264
+ renderPins();
265
+ }, 400);
266
+
267
+ // ---------------------------------------------------------------- overlay
268
+
269
+ function box(rect, cls, text) {
270
+ const el = document.createElement('div');
271
+ el.className = `box ${cls}`;
272
+ Object.assign(el.style, {
273
+ left: `${rect.x}px`,
274
+ top: `${rect.y}px`,
275
+ width: `${rect.width}px`,
276
+ height: `${rect.height}px`,
277
+ });
278
+ if (text) {
279
+ const tag = document.createElement('span');
280
+ tag.className = 'tag';
281
+ tag.textContent = text;
282
+ el.append(tag);
283
+ }
284
+ return el;
285
+ }
286
+
287
+ let pins = [];
288
+
289
+ function renderPins() {
290
+ const route = currentRoute();
291
+ pins = [];
292
+ let doc;
293
+ try {
294
+ doc = frame.contentDocument;
295
+ } catch {
296
+ return;
297
+ }
298
+ for (const a of state.annotations) {
299
+ if (a.status !== 'open' || a.route !== route || !a.targets.length) continue;
300
+ let el = state.refs.get(a.id)?.find((e) => e.isConnected);
301
+ if (!el) {
302
+ try {
303
+ el = doc?.querySelector(a.targets[0].selector);
304
+ } catch {}
305
+ }
306
+ if (el) pins.push({ id: a.id, el });
307
+ }
308
+ }
309
+
310
+ function draw() {
311
+ const layers = [];
312
+ const hover = state.hover;
313
+ if (state.picking && hover?.isConnected && !state.selection.some((s) => s.el === hover)) {
314
+ layers.push(box(hover.getBoundingClientRect(), 'hover', state.hoverLabel));
315
+ }
316
+ for (const s of state.selection) {
317
+ if (s.el.isConnected) layers.push(box(s.el.getBoundingClientRect(), 'selected', label(s.info)));
318
+ }
319
+ for (const p of pins) {
320
+ if (!p.el.isConnected) continue;
321
+ const r = p.el.getBoundingClientRect();
322
+ const pin = document.createElement('div');
323
+ pin.className = 'pin';
324
+ pin.textContent = p.id;
325
+ pin.style.left = `${r.x + r.width}px`;
326
+ pin.style.top = `${r.y}px`;
327
+ pin.onclick = () => {
328
+ switchTab('comments');
329
+ setThread(p.id);
330
+ };
331
+ layers.push(pin);
332
+ }
333
+ overlay.replaceChildren(...layers);
334
+ requestAnimationFrame(draw);
335
+ }
336
+ requestAnimationFrame(draw);
337
+
338
+ // ---------------------------------------------------------------- panel
339
+
340
+ function switchTab(tab) {
341
+ state.tab = tab;
342
+ for (const b of document.querySelectorAll('.tabs button'))
343
+ b.classList.toggle('active', b.dataset.tab === tab);
344
+ $('chat').hidden = tab !== 'chat';
345
+ $('comments').hidden = tab !== 'comments';
346
+ }
347
+ for (const b of document.querySelectorAll('.tabs button'))
348
+ b.onclick = () => switchTab(b.dataset.tab);
349
+
350
+ function setSelection(selection) {
351
+ state.selection = selection;
352
+ renderContext();
353
+ }
354
+
355
+ function setThread(id) {
356
+ state.thread = id;
357
+ renderContext();
358
+ renderComments();
359
+ if (id) input.focus();
360
+ }
361
+
362
+ function renderContext() {
363
+ const chips = state.selection.map((s, i) => {
364
+ const chip = document.createElement('span');
365
+ chip.className = 'chip';
366
+ chip.title = [where(s.info.source), ...s.info.stack.map((f) => `${f.name ?? ''} ${where(f)}`)]
367
+ .filter(Boolean)
368
+ .join('\n');
369
+ chip.textContent = label(s.info);
370
+ const x = document.createElement('button');
371
+ x.type = 'button';
372
+ x.textContent = '×';
373
+ x.onclick = () => setSelection(state.selection.filter((_, j) => j !== i));
374
+ chip.append(x);
375
+ return chip;
376
+ });
377
+ if (state.thread) {
378
+ const chip = document.createElement('span');
379
+ chip.className = 'chip';
380
+ chip.textContent = `replying to #${state.thread}`;
381
+ const x = document.createElement('button');
382
+ x.type = 'button';
383
+ x.textContent = '×';
384
+ x.onclick = () => setThread(null);
385
+ chip.append(x);
386
+ chips.unshift(chip);
387
+ }
388
+ $('context').replaceChildren(...chips);
389
+ input.placeholder = state.thread
390
+ ? `Reply to #${state.thread}…`
391
+ : state.selection.length
392
+ ? 'What should change here?'
393
+ : 'Message the agent…';
394
+ }
395
+
396
+ function nearBottom(el) {
397
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
398
+ }
399
+
400
+ function entryNode(e) {
401
+ const el = document.createElement('div');
402
+ el.className = `msg ${e.kind}`;
403
+ if (e.kind === 'assistant') el.innerHTML = markdown(e.text);
404
+ else el.textContent = e.kind === 'event' ? `· ${e.text} ·` : e.text;
405
+ return el;
406
+ }
407
+
408
+ function renderChat(fresh) {
409
+ const feed = $('chat');
410
+ const stick = nearBottom(feed);
411
+ if (!fresh) {
412
+ if (!state.session) {
413
+ feed.innerHTML =
414
+ '<p class="empty">No Claude session attached.<br>Start <code>yubi up</code> from inside the agent\'s session or pass <code>--session &lt;id&gt;</code>.</p>';
415
+ return;
416
+ }
417
+ feed.replaceChildren(...state.transcript.map(entryNode));
418
+ } else {
419
+ feed.append(...fresh.map(entryNode));
420
+ }
421
+ if (stick || !fresh) feed.scrollTop = feed.scrollHeight;
422
+ }
423
+
424
+ function renderComments() {
425
+ const feed = $('comments');
426
+ const open = state.annotations.filter((a) => a.status === 'open').length;
427
+ $('count').textContent = open ? `(${open})` : '';
428
+ if (!state.annotations.length) {
429
+ feed.innerHTML =
430
+ '<p class="empty">Press <code>C</code>, click an element, and say what should change.</p>';
431
+ return;
432
+ }
433
+ const cards = [...state.annotations].reverse().map((a) => {
434
+ const card = document.createElement('article');
435
+ card.className = `card${a.status === 'open' ? '' : ' closed'}${state.thread === a.id ? ' focus' : ''}`;
436
+ const targets = a.targets
437
+ .map(
438
+ (t) =>
439
+ `<div class="where" title="${esc(t.stack.map((f) => `${f.name ?? ''} ${where(f)}`).join('\n'))}">${esc(label(t))}</div>`,
440
+ )
441
+ .join('');
442
+ const thread = a.thread
443
+ .map(
444
+ (m) =>
445
+ `<div class="${m.from}">${m.from === 'agent' ? markdown(m.text) : esc(m.text)}</div>`,
446
+ )
447
+ .join('');
448
+ card.innerHTML = `
449
+ <header><span class="id">#${a.id}</span><span class="badge ${a.status}">${a.status}</span>
450
+ <span class="where">${esc(a.route)}</span></header>
451
+ ${targets}<div class="thread">${thread}</div>
452
+ <div class="actions"></div>`;
453
+ const actions = card.querySelector('.actions');
454
+ const action = (text, fn) => {
455
+ const b = document.createElement('button');
456
+ b.type = 'button';
457
+ b.textContent = text;
458
+ b.onclick = fn;
459
+ actions.append(b);
460
+ };
461
+ action('Reply', () => setThread(a.id));
462
+ if (a.status === 'open')
463
+ action('Resolve', () => api(`/annotations/${a.id}/messages`, { status: 'resolved' }));
464
+ else action('Reopen', () => api(`/annotations/${a.id}/messages`, { status: 'open' }));
465
+ return card;
466
+ });
467
+ feed.replaceChildren(...cards);
468
+ }
469
+
470
+ function setListening(on) {
471
+ const el = $('status');
472
+ el.classList.toggle('on', on);
473
+ el.textContent = on ? 'agent listening' : 'agent busy';
474
+ el.title = on
475
+ ? 'The agent is waiting for your messages.'
476
+ : 'Messages queue until the agent runs `yubi wait` again.';
477
+ }
478
+
479
+ $('composer').addEventListener('submit', async (e) => {
480
+ e.preventDefault();
481
+ const text = input.value.trim();
482
+ if (!text) return;
483
+ input.disabled = true;
484
+ try {
485
+ if (state.thread) {
486
+ await api(`/annotations/${state.thread}/messages`, { text });
487
+ } else {
488
+ const picked = state.selection;
489
+ const created = await api('/annotations', {
490
+ comment: text,
491
+ route: currentRoute(),
492
+ viewport: { width: frame.clientWidth, height: frame.clientHeight },
493
+ targets: picked.map((s) => s.info),
494
+ });
495
+ if (picked.length)
496
+ state.refs.set(
497
+ created.id,
498
+ picked.map((s) => s.el),
499
+ );
500
+ setSelection([]);
501
+ }
502
+ input.value = '';
503
+ } catch (err) {
504
+ alert(`yubisashi: ${err.message}`);
505
+ } finally {
506
+ input.disabled = false;
507
+ input.focus();
508
+ }
509
+ });
510
+
511
+ input.addEventListener('keydown', (e) => {
512
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
513
+ e.preventDefault();
514
+ $('composer').requestSubmit();
515
+ }
516
+ });
517
+ document.addEventListener('keydown', onKey, true);
518
+ $('pick').onclick = () => setPicking(!state.picking);
519
+ $('reload').onclick = () => frame.contentWindow.location.reload();
520
+
521
+ // ---------------------------------------------------------------- boot
522
+
523
+ async function boot() {
524
+ if (!token) {
525
+ document.body.innerHTML =
526
+ '<p class="empty" style="margin:auto">Missing token. Open the URL printed by <code>yubi up</code> (or run <code>yubi url</code>).</p>';
527
+ return;
528
+ }
529
+ const initial = await api('/state');
530
+ state.annotations = initial.annotations;
531
+ state.transcript = initial.transcript;
532
+ state.session = initial.session;
533
+ $('open').href = initial.target;
534
+ frame.src = hash.get('route') || '/';
535
+ setListening(initial.listening);
536
+ renderChat();
537
+ renderComments();
538
+ renderContext();
539
+
540
+ const events = new EventSource(`api/events?token=${encodeURIComponent(token)}`);
541
+ events.addEventListener('listening', (e) => setListening(JSON.parse(e.data)));
542
+ events.addEventListener('annotations', (e) => {
543
+ state.annotations = JSON.parse(e.data);
544
+ renderComments();
545
+ renderPins();
546
+ });
547
+ events.addEventListener('transcript', (e) => {
548
+ const fresh = JSON.parse(e.data);
549
+ state.transcript.push(...fresh);
550
+ renderChat(fresh);
551
+ });
552
+ }
553
+
554
+ boot();
@@ -0,0 +1,46 @@
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>yubisashi</title>
7
+ <link rel="stylesheet" href="app.css" />
8
+ </head>
9
+ <body>
10
+ <main id="stage">
11
+ <header id="bar">
12
+ <button id="pick" type="button" title="Comment on elements (C)">
13
+ <span class="glyph">⌖</span> Comment
14
+ </button>
15
+ <span id="route"></span>
16
+ <button id="reload" type="button" title="Reload the app">↻</button>
17
+ <a id="open" href="/" target="_blank" rel="noreferrer" title="Open the app on its own">↗</a>
18
+ </header>
19
+ <div id="viewport">
20
+ <iframe id="app" title="app under review"></iframe>
21
+ <div id="overlay"></div>
22
+ <div id="glass" hidden></div>
23
+ </div>
24
+ </main>
25
+ <aside id="panel">
26
+ <header>
27
+ <nav class="tabs">
28
+ <button type="button" data-tab="chat" class="active">Conversation</button>
29
+ <button type="button" data-tab="comments">Comments <span id="count"></span></button>
30
+ </nav>
31
+ <span id="status" title=""></span>
32
+ </header>
33
+ <section id="chat" class="feed"></section>
34
+ <section id="comments" class="feed" hidden></section>
35
+ <form id="composer">
36
+ <div id="context"></div>
37
+ <textarea id="input" rows="3" placeholder="Message the agent…"></textarea>
38
+ <div class="row">
39
+ <span id="hint">C to point at elements · Shift+click to add · Enter to send</span>
40
+ <button type="submit">Send</button>
41
+ </div>
42
+ </form>
43
+ </aside>
44
+ <script type="module" src="app.js"></script>
45
+ </body>
46
+ </html>
package/skill/SKILL.md ADDED
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: yubisashi
3
+ description: Use when the user wants to review a running web app visually and send you feedback by pointing at elements — "let me comment on the UI", "start a review session", "I'll show you what I don't like". Starts the dev server behind the yubisashi shell (app + conversation panel side by side) and wires the user's comments into this session.
4
+ ---
5
+
6
+ # yubisashi: UI review loop
7
+
8
+ The user points at elements in their running app, writes a comment, and it arrives here with
9
+ the element's source `file:line`, the component chain that rendered it, a selector and an HTML
10
+ snippet. Their view also streams this conversation next to the app.
11
+
12
+ ## Start
13
+
14
+ 1. Start the dev server behind yubisashi as a **background** command, from the app's directory:
15
+
16
+ ```sh
17
+ yubi up --target http://localhost:5173 -- npm run dev
18
+ ```
19
+
20
+ `--target` is the dev server's URL. Leave out `-- <cmd>` if the server is already running.
21
+ It listens on 127.0.0.1:4780 by default (`--port`, `--host`).
22
+
23
+ 2. Run `yubi url` and give the user the URL it prints (it carries the access token).
24
+
25
+ 3. Start listening as a **background** command:
26
+
27
+ ```sh
28
+ yubi wait
29
+ ```
30
+
31
+ It exits when the user sends something, and its output is their messages. The harness
32
+ wakes you up when it exits, so you don't need to poll.
33
+
34
+ ## Each time `yubi wait` returns
35
+
36
+ 1. Read every message. `source:` is where the element is written; `rendered by:` lists the
37
+ components that rendered it, innermost first. These are the files to look at.
38
+ 2. Do the work. The dev server hot-reloads, so the user sees the change in the shell.
39
+ 3. Answer each thread by id:
40
+ - `yubi resolve <id> "what changed"` when it's done
41
+ - `yubi reply <id> "question or progress"` to keep the thread open
42
+ - `yubi dismiss <id> "why"` to decline
43
+ 4. Start `yubi wait` in the background again. Until you do, the panel shows "agent busy" and
44
+ messages queue up.
45
+
46
+ Messages without a target are general chat from the panel. Answer them in your normal
47
+ response, since the user reads this conversation in the panel.
48
+
49
+ `yubi list` shows open comments. Stop the loop only when the user says they're done, then stop
50
+ the `yubi up` background task.