@botbuddy/cli 1.67.0 → 1.68.1

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,992 @@
1
+ // BOT-2644 / BOT-3152. Host code only: no model, prompt, transcript or tool-registration APIs.
2
+ // The host side runs from the installed bb CLI (`bb status-band read|relay`).
3
+ const PANE = "bb-status";
4
+ const UUID =
5
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6
+ let mode = "live";
7
+ let selectedTicket;
8
+ let snapshot = null;
9
+ let errorCode = null;
10
+ let loading = false;
11
+ let generation = 0;
12
+ let currentTime = 0;
13
+ let activity = "Idle";
14
+ let timer;
15
+ // BOT-3135 relay: one `bb wait` child at a time, owned by `relayEpoch`.
16
+ // `relayFault` is why it stopped; while `relayArmed` the band is current.
17
+ let relayEpoch = 0;
18
+ let relayStream = null;
19
+ let relayArmed = false;
20
+ let keptLive = false;
21
+ let relayFault = null;
22
+ let backoffMs = 0;
23
+ const WAKE_READ_GAP_MS = 15000;
24
+ // BOT-3152 AC-4: while no surface draws this session the relay is parked (no
25
+ // child, no server wait); the 5 s display tick un-parks it with one read.
26
+ let parked = false;
27
+ // BOT-3152 AC-5: a repo with no BB binding shows no band at all (the mod is
28
+ // installed once at user scope, so it also loads in unrelated repos).
29
+ let unbound = false;
30
+ const ERRORS = {
31
+ linear_key_missing:
32
+ "Export LINEAR_BB_API_KEY to Claude Desktop before using Live.",
33
+ session_missing: "Connect BB in this worktree before using Live.",
34
+ harness_session_missing:
35
+ "This Claude session has no supported session identifier.",
36
+ session_mismatch:
37
+ "BB identity belongs to another session. Connect BB for this Claude session.",
38
+ session_changed: "BB session changed during refresh. Refresh again.",
39
+ session_invalid: "BB session is invalid. Reconnect BB in this worktree.",
40
+ session_expired: "BB session expired. Reconnect BB in this worktree.",
41
+ session_file_unsafe: "BB session file must be private (mode 0600).",
42
+ tenant_mismatch: "This repo is bound to a tenant BB status does not support.",
43
+ agent_mismatch:
44
+ "BB returned a different agent. Reconnect BB for this session.",
45
+ worktree_mismatch: "BB identity belongs to a different worktree.",
46
+ ticket_missing: "Use a ticket branch (bot-… or ent-…) or /bb-status live BOT-2644.",
47
+ ticket_scope_mismatch: "Linear issue or workspace does not match BotBuddy.",
48
+ blockers_incomplete: "Blocker list is incomplete. Status is unknown.",
49
+ source_unauthorized:
50
+ "BB or Linear refused access. Check the existing connection.",
51
+ source_unreachable: "BB or Linear could not be reached. Refresh to retry.",
52
+ source_unavailable: "A status source is unavailable. Refresh to retry.",
53
+ cli_missing: "Install the bb CLI (npm i -g @botbuddy/cli), then run bb setup.",
54
+ cli_outdated: "Update the bb CLI (bb update) to use this band.",
55
+ };
56
+ function errorText() {
57
+ return (
58
+ ERRORS[errorCode] ||
59
+ "Status unavailable. Check BB and Linear connections, then refresh."
60
+ );
61
+ }
62
+ function safeLabel(value, max = 240) {
63
+ return (
64
+ typeof value === "string" &&
65
+ value.trim().length > 0 &&
66
+ value.length <= max &&
67
+ !/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/u.test(value)
68
+ );
69
+ }
70
+ function safeLink(value, key) {
71
+ try {
72
+ const u = new URL(value);
73
+ return (
74
+ u.protocol === "https:" &&
75
+ u.hostname === "linear.app" &&
76
+ !u.port &&
77
+ !u.username &&
78
+ !u.password &&
79
+ !u.search &&
80
+ !u.hash &&
81
+ new RegExp("^/botbuddy/issue/" + key + "(?:/|$)").test(u.pathname)
82
+ );
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+ // Each tenant owns one key prefix; a snapshot's keys must carry its own.
88
+ const TENANT_PREFIX = { botbuddy: "BOT", "supply-guard": "ENT" };
89
+ const KEY = /^[A-Z]{2,10}-[1-9]\d{0,5}$/;
90
+ const PROJECT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
91
+ const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
92
+ const PR_STATES = ["open", "draft", "merged", "closed"];
93
+ const LINEAR_PROJECT_URL = /^https:\/\/linear\.app\/[a-z0-9-]{1,64}\/project\/[a-z0-9-]{1,200}$/;
94
+ const PROJECT_IDENTIFIER = /^P-[A-Z][A-Z0-9]{1,9}-[1-9]\d{0,5}$/;
95
+ const PROJECT_STATES = ["backlog", "planned", "started", "paused", "completed", "canceled"];
96
+ function validSnapshot(value, harness, now) {
97
+ const prefix = TENANT_PREFIX[value?.tenant];
98
+ const ownKey = (k) => KEY.test(k) && k.startsWith(prefix + "-");
99
+ if (
100
+ !value ||
101
+ value.schema_version !== 2 ||
102
+ !prefix ||
103
+ value.harness_session_id !== harness ||
104
+ !UUID.test(value.session_id) ||
105
+ !UUID.test(value.agent?.id) ||
106
+ !safeLabel(value.agent?.name, 100) ||
107
+ !safeLabel(value.branch) ||
108
+ !Array.isArray(value.tickets) ||
109
+ value.tickets.length < 1 ||
110
+ value.tickets.length > 5 ||
111
+ !value.tickets.every(
112
+ (t) => ownKey(t?.key) && safeLabel(t.title) && safeLabel(t.status, 40),
113
+ ) ||
114
+ value.ticket?.key !== value.tickets[0].key ||
115
+ !safeLabel(value.ticket.title) ||
116
+ !safeLabel(value.ticket.status, 40) ||
117
+ (value.ticket.url !== null && !safeLink(value.ticket.url, value.ticket.key)) ||
118
+ (value.prs !== null &&
119
+ !(
120
+ Array.isArray(value.prs) &&
121
+ value.prs.length <= 5 &&
122
+ value.prs.every(
123
+ (p) =>
124
+ REPO.test(p?.repo) &&
125
+ Number.isSafeInteger(p.number) &&
126
+ p.number > 0 &&
127
+ safeLabel(p.title) &&
128
+ PR_STATES.includes(p.state) &&
129
+ typeof p.conflict === "boolean" &&
130
+ (p.reviews == null || (Number.isSafeInteger(p.reviews) && p.reviews >= 0 && p.reviews <= 999)),
131
+ )
132
+ )) ||
133
+ (value.epic !== null &&
134
+ !(
135
+ PROJECT.test(value.epic?.id) &&
136
+ /^[0-9a-f]{8,16}$/.test(value.epic.short_id) &&
137
+ safeLabel(value.epic.name) &&
138
+ PROJECT_STATES.includes(value.epic.state) &&
139
+ (value.epic.url === null ||
140
+ (LINEAR_PROJECT_URL.test(value.epic.url) &&
141
+ value.epic.url.startsWith("https://linear.app/" + LINEAR_WORKSPACE[value.tenant] + "/"))) &&
142
+ (value.epic.identifier == null || PROJECT_IDENTIFIER.test(value.epic.identifier)) &&
143
+ Number.isSafeInteger(value.epic.percent) &&
144
+ value.epic.percent >= 0 &&
145
+ value.epic.percent <= 100
146
+ )) ||
147
+ (value.blockers !== null &&
148
+ !(value.tenant === "botbuddy" && Array.isArray(value.blockers) && value.blockers.length <= 100)) ||
149
+ (value.epic_unreadable !== undefined && typeof value.epic_unreadable !== "boolean") ||
150
+ (value.staging != null &&
151
+ !(
152
+ typeof value.staging.configured === "boolean" &&
153
+ typeof value.staging.red === "boolean" &&
154
+ (value.staging.since === null || Number.isFinite(Date.parse(value.staging.since)))
155
+ ))
156
+ )
157
+ return false;
158
+ const observed = Date.parse(value.observed_at);
159
+ if (
160
+ !Number.isFinite(observed) ||
161
+ observed > now + 5000 ||
162
+ now - observed > 60000
163
+ )
164
+ return false;
165
+ return (value.blockers || []).every(
166
+ (b) =>
167
+ /^BOT-[1-9]\d*$/.test(b.key) &&
168
+ safeLabel(b.title) &&
169
+ safeLabel(b.status) &&
170
+ safeLink(b.url, b.key),
171
+ );
172
+ }
173
+ // Canonical BB drawers (?card=…, any route; BOT-2896/BOT-3100/BOT-3136). A PR
174
+ // card id is owner/repo#n; its `#` travels as %23 so it stays in the query.
175
+ const APP = "https://app.bot-buddy.ai";
176
+ const LINKS = {
177
+ ticket: (key) => APP + "/kanban?card=ticket:" + key,
178
+ project: (id) => APP + "/kanban?card=project:" + id,
179
+ pr: (repo, n) => APP + "/kanban?card=pr:" + repo + "%23" + n,
180
+ // BOT-3152 AC-2: the web top bar's Staging chip link (STAGING_HREF).
181
+ staging: APP + "/release-readiness/staging/latest",
182
+ };
183
+ // The ↗ after an id goes straight to the source: Linear for tickets and the
184
+ // epic, GitHub for PRs. Each tenant's tickets live in its own Linear workspace.
185
+ const LINEAR_WORKSPACE = { botbuddy: "botbuddy", "supply-guard": "supply-guard" };
186
+ const OUT = {
187
+ ticket: (tenant, key) => "https://linear.app/" + LINEAR_WORKSPACE[tenant] + "/issue/" + key,
188
+ pr: (repo, n) => "https://github.com/" + repo + "/pull/" + n,
189
+ };
190
+ const PR_LABEL = { open: "Open", draft: "Draft", merged: "Merged", closed: "Closed" };
191
+ function demoSnapshot(now) {
192
+ return {
193
+ observed_at: new Date(now).toISOString(),
194
+ branch: "jono/bot-2644-claude-status-mod",
195
+ tenant: "botbuddy",
196
+ agent: { name: "Zara Steyn (demo)" },
197
+ // Illustrative only: several tickets and PRs in different states and long
198
+ // titles, so the demo exercises wrapping and the aligned columns.
199
+ ticket: {
200
+ key: "BOT-2644",
201
+ title: "Show the session's tickets, PRs and epic inside Claude so an operator can see what the agent is working on without a model turn (example)",
202
+ status: "In Progress",
203
+ url: "https://linear.app/botbuddy/issue/BOT-2644",
204
+ },
205
+ tickets: [
206
+ { key: "BOT-2644", title: "Show the session's tickets, PRs and epic inside Claude so an operator can see what the agent is working on without a model turn (example)", status: "In Progress" },
207
+ { key: "BOT-2645", title: "Status icons follow Linear (example)", status: "In Review" },
208
+ { key: "BOT-2646", title: "Aligned columns in the band (example)", status: "Done" },
209
+ ],
210
+ prs: [
211
+ { repo: "bot-buddy/botbuddy-webapp", number: 1585, title: "feat(BOT-2644): three aligned lines above the prompt, one status icon per ticket and PR, titles that wrap instead of clipping (example)", state: "open", conflict: true, reviews: 2 },
212
+ { repo: "bot-buddy/botbuddy-webapp", number: 1586, title: "Status icons (example)", state: "draft", conflict: false, reviews: 0 },
213
+ { repo: "bot-buddy/botbuddy-webapp", number: 1571, title: "Card links (example)", state: "merged", conflict: false, reviews: 1 },
214
+ ],
215
+ epic: {
216
+ id: "00000000-0000-4000-8000-000000000000",
217
+ short_id: "a1b2c3d4e5f6",
218
+ identifier: null,
219
+ url: "https://linear.app/botbuddy/project/claude-mods-a1b2c3d4e5f6",
220
+ name: "Claude mods: BB session status, the design gallery and live updates through the bb wait relay (example)",
221
+ state: "started",
222
+ done: 5,
223
+ total: 11,
224
+ percent: 45,
225
+ },
226
+ staging: { configured: true, red: true, since: new Date(now - 12 * 60000).toISOString() },
227
+ // Demonstration-only IDs; never interpreted as current dependency state.
228
+ blockers: [
229
+ {
230
+ key: "BOT-1200",
231
+ title: "Example dependency",
232
+ status: "In Progress",
233
+ url: "https://linear.app/botbuddy/issue/BOT-1200",
234
+ },
235
+ ],
236
+ };
237
+ }
238
+ // `keep` (Refresh of the same selection) and `wake` leave the drawn snapshot
239
+ // on screen until the new one lands; a new selection or mode clears it first.
240
+ async function refresh($, { wake = false, keep = false } = {}) {
241
+ const request = ++generation;
242
+ if (!wake) parked = false;
243
+ if (!wake) {
244
+ // A Refresh of a live band stays "Live" until its read lands (review on #1598).
245
+ keptLive = keep && relayArmed && !relayFault && !!snapshot;
246
+ stopRelay();
247
+ if (!keep) snapshot = null;
248
+ }
249
+ errorCode = null;
250
+ loading = true;
251
+ unbound = false;
252
+ $.ui.invalidate("ui.render");
253
+ try {
254
+ currentTime = await $.clock.now();
255
+ if (mode === "demo") {
256
+ snapshot = demoSnapshot(currentTime);
257
+ return;
258
+ }
259
+ const harness = await $.session.id();
260
+ if (!UUID.test(harness)) throw new Error("harness_session_missing");
261
+ const cwd = await $.session.cwd();
262
+ const argv = ["bb", "status-band", "read", "--harness-session-id", harness];
263
+ if (selectedTicket) argv.push("--ticket-id", selectedTicket);
264
+ // BOT-3135: a wake re-read skips the slow epic read and keeps the last one.
265
+ const previous = wake ? snapshot : null;
266
+ if (previous) argv.push("--skip-epic");
267
+ let result;
268
+ try {
269
+ result = await $.process.run(argv, { cwd, timeoutMs: 25000 });
270
+ } catch {
271
+ throw new Error("cli_missing");
272
+ }
273
+ if (request !== generation) return;
274
+ if (typeof result.stdout !== "string" || result.stdout.length > 100000)
275
+ throw new Error("invalid_response");
276
+ let data;
277
+ try {
278
+ data = JSON.parse(result.stdout);
279
+ } catch {
280
+ // A bb older than `status-band` prints its usage error, not JSON.
281
+ throw new Error(/Unknown command/.test(String(result.stderr)) ? "cli_outdated" : "invalid_response");
282
+ }
283
+ if (result.exitCode !== 0 && data?.error === "repo_binding_missing") {
284
+ unbound = true;
285
+ snapshot = null;
286
+ return;
287
+ }
288
+ if (result.exitCode !== 0)
289
+ throw new Error(
290
+ typeof data.error === "string" ? data.error : "source_unavailable",
291
+ );
292
+ currentTime = await $.clock.now();
293
+ if (request !== generation) return;
294
+ if (previous && data && data.epic_skipped === true) data = keepKnown(data, previous);
295
+ if (
296
+ !validSnapshot(data, harness, currentTime) ||
297
+ (await $.session.id()) !== harness
298
+ )
299
+ throw new Error("session_changed");
300
+ if (request === generation) {
301
+ snapshot = data;
302
+ relayFault = null;
303
+ if (!wake) startRelay($);
304
+ }
305
+ } catch (error) {
306
+ if (request === generation) {
307
+ if (wake) stopRelay();
308
+ snapshot = null;
309
+ errorCode = Object.hasOwn(ERRORS, error.message)
310
+ ? error.message
311
+ : "status_unavailable";
312
+ }
313
+ } finally {
314
+ if (request === generation) {
315
+ loading = false;
316
+ keptLive = false;
317
+ $.ui.invalidate("ui.render");
318
+ }
319
+ }
320
+ }
321
+ // ── BOT-3135 relay ─────────────────────────────────────────────────────
322
+ // What the band waits on: each ticket, each PR still open or draft, and, when
323
+ // BotBuddy blockers are listed, each ticket's last blocker clearing, and the
324
+ // epic's Linear project (BOT-3136).
325
+ const MAX_CONDITIONS = 18;
326
+ function waitConditions(s) {
327
+ // BOT-3152 AC-2: unknown staging (null: a failed read) also wakes on it
328
+ // (review on #1616), so the next incident re-reads; only a tenant known to
329
+ // have no staging skips it.
330
+ const staging = s.staging?.configured === false ? [] : ["event:type=staging_incident"];
331
+ const conds = s.tickets.map((t) => "linear:issue=" + t.key);
332
+ for (const p of s.prs || [])
333
+ if (p.state === "open" || p.state === "draft")
334
+ conds.push("pr-state:repo=" + p.repo + ",pr=" + p.number);
335
+ // Blockers are read for the primary ticket only, and an `unblocked:` wait on
336
+ // a ticket that is not blocked is granted at once: never arm it for others.
337
+ if (s.tenant === "botbuddy" && s.blockers && s.blockers.length > 0)
338
+ conds.push("unblocked:" + s.ticket.key);
339
+ // BOT-3136 AC-1: the epic row wakes on its Linear project's progress.
340
+ if (s.epic && UUID.test(s.epic.id))
341
+ conds.push("event:type=linear_project,subject=" + s.epic.id.toLowerCase());
342
+ // BOT-3152 AC-3 (review on #1616): a clean review round moves only
343
+ // review_rounds, which no pr-state signal carries, so each open PR also wakes
344
+ // on its reviews. Last, so a full list drops these before anything else.
345
+ for (const p of s.prs || [])
346
+ if (p.state === "open" || p.state === "draft")
347
+ conds.push("pr-review:repo=" + p.repo + ",pr=" + p.number);
348
+ // At most 5 tickets, 5 PRs (state + review), blockers, epic and staging: 18,
349
+ // the relay's limit, so no wake is ever dropped (review on #1616, round 4).
350
+ return [...conds, ...staging].slice(0, MAX_CONDITIONS);
351
+ }
352
+ // Ending the loop's stream kills the child; a module unload does the same.
353
+ function stopRelay() {
354
+ relayEpoch++;
355
+ relayArmed = false;
356
+ const stream = relayStream;
357
+ relayStream = null;
358
+ if (stream) stream.return?.().catch(() => {});
359
+ }
360
+ function delay($, ms) {
361
+ return new Promise((resolve) => $.clock.after(ms, resolve));
362
+ }
363
+ async function relayOnce($, epoch, since) {
364
+ const cwd = await $.session.cwd();
365
+ // A Refresh during that await started a new loop: this one must not spawn.
366
+ if (epoch !== relayEpoch) return null;
367
+ const argv = ["bb", "status-band", "relay"];
368
+ for (const c of waitConditions(snapshot)) argv.push("--cond", c);
369
+ if (since) argv.push("--since", since);
370
+ let stream;
371
+ try {
372
+ stream = $.process.spawn({ argv, cwd });
373
+ } catch {
374
+ return { exit: 4 };
375
+ }
376
+ relayStream = stream;
377
+ let out = "";
378
+ for (;;) {
379
+ const piece = await stream.next();
380
+ if (piece.done) break;
381
+ if (piece.value.stream === "stdout" && out.length < 20000) out += piece.value.text;
382
+ }
383
+ if (epoch !== relayEpoch) return null;
384
+ relayStream = null;
385
+ try {
386
+ return JSON.parse(out.trim().split("\n").pop());
387
+ } catch {
388
+ return { exit: 7 };
389
+ }
390
+ }
391
+ function startRelay($) {
392
+ stopRelay();
393
+ // Review on #1616: a read that lands after the session was hidden arms nothing.
394
+ if (mode !== "live" || !snapshot || parked) return;
395
+ const epoch = relayEpoch;
396
+ backoffMs = 0;
397
+ relayArmed = true;
398
+ void (async () => {
399
+ let since = null;
400
+ let expired = 0;
401
+ let lastWakeRead = -Infinity;
402
+ // Reads a relay asks for are at least 15 s apart, whatever bb returns.
403
+ const wakeRead = async () => {
404
+ const wait = lastWakeRead + WAKE_READ_GAP_MS - (await $.clock.now());
405
+ if (wait > 0) await delay($, wait);
406
+ const at = await $.clock.now();
407
+ // Checked after the last await: a Refresh begun meanwhile owns the relay now.
408
+ if (epoch !== relayEpoch) return;
409
+ lastWakeRead = at;
410
+ await refresh($, { wake: true });
411
+ };
412
+ while (epoch === relayEpoch) {
413
+ let res;
414
+ try {
415
+ res = await relayOnce($, epoch, since);
416
+ } catch {
417
+ res = { exit: 7 };
418
+ }
419
+ if (!res || epoch !== relayEpoch) return;
420
+ const cursor = typeof res.next_cursor === "string" ? res.next_cursor : null;
421
+ if (res.exit === 0) {
422
+ // A wake: one read, then arm again from the wait's cursor.
423
+ since = cursor;
424
+ expired = 0;
425
+ backoffMs = 0;
426
+ await wakeRead();
427
+ } else if (res.exit === 6) {
428
+ // An expired cursor: read and re-arm without one; a run of them backs off.
429
+ since = null;
430
+ if (++expired > 2) {
431
+ backoffMs = Math.min(backoffMs ? backoffMs * 2 : 30000, 300000);
432
+ await delay($, backoffMs);
433
+ if (epoch !== relayEpoch) return;
434
+ }
435
+ await wakeRead();
436
+ } else if (res.exit === 2) {
437
+ since = cursor || since;
438
+ } else if (res.exit === 5) {
439
+ backoffMs = Math.min(backoffMs ? backoffMs * 2 : 30000, 300000);
440
+ since = cursor || since;
441
+ await delay($, backoffMs);
442
+ if (epoch !== relayEpoch) return;
443
+ await wakeRead();
444
+ } else {
445
+ // 3 auth, 4 invalid, 8 superseded, anything else: stop, never loop.
446
+ relayFault = res.exit === 3 ? "session_expired" : "relay_stopped";
447
+ relayArmed = false;
448
+ $.ui.invalidate("ui.render");
449
+ return;
450
+ }
451
+ if (epoch !== relayEpoch || !snapshot || mode !== "live") return;
452
+ }
453
+ })();
454
+ }
455
+ // A wake read omits the epic and may not place a quiet secondary ticket
456
+ // (BB's work list covers 3 days); keep what the last full read knew.
457
+ function keepKnown(data, previous) {
458
+ const { epic_skipped, ...rest } = data;
459
+ const samePrimary = previous.tenant === data.tenant && previous.ticket.key === data.ticket.key;
460
+ const known = new Map(previous.tickets.map((t) => [t.key, t]));
461
+ return {
462
+ ...rest,
463
+ // Only the same primary ticket in the same tenant keeps the last epic, and
464
+ // with it whether that epic could not be read (review on #1616).
465
+ epic: samePrimary ? previous.epic : null,
466
+ epic_unreadable: samePrimary ? previous.epic_unreadable === true : false,
467
+ tickets: data.tickets.map((t) =>
468
+ t.status === "Unknown" && known.has(t.key) ? known.get(t.key) : t,
469
+ ),
470
+ };
471
+ }
472
+ // ── BOT-3152 AC-4: only a session on screen holds a live relay ─────────────
473
+ // `$.session.surfaces()` lists the surfaces drawing this session: empty while
474
+ // Desktop shows another session (POC 2026-11-12, Claude Code 2.1.321). A read
475
+ // that fails counts as on screen, so a live band is never parked by mistake.
476
+ async function onScreen($) {
477
+ try {
478
+ const surfaces = await $.session.surfaces();
479
+ return !Array.isArray(surfaces) || surfaces.length > 0;
480
+ } catch {
481
+ return true;
482
+ }
483
+ }
484
+ async function followVisibility($) {
485
+ if (mode !== "live" || unbound) return;
486
+ const shown = await onScreen($);
487
+ if (!shown && !parked) {
488
+ parked = true;
489
+ stopRelay();
490
+ } else if (shown && parked && !loading) {
491
+ // One read, then the relay re-arms (refresh starts it on success). While a
492
+ // read is still in flight the session stays parked, so the next tick
493
+ // resumes it (review on #1616).
494
+ parked = false;
495
+ await refresh($, { keep: true });
496
+ }
497
+ }
498
+ function stale() {
499
+ if (mode !== "live" || !snapshot || parked) return false;
500
+ if (relayFault) return true;
501
+ return !relayArmed && !keptLive && currentTime - Date.parse(snapshot.observed_at) > 60000;
502
+ }
503
+ function staleText() {
504
+ if (relayFault === "session_expired")
505
+ return "STALE · BB session expired. Reconnect BB in this worktree, then Refresh.";
506
+ if (relayFault) return "STALE · Live updates stopped. Refresh to check current status.";
507
+ return "STALE · Refresh to check current status";
508
+ }
509
+ async function openPanel($) {
510
+ await $.ui.open({
511
+ id: PANE,
512
+ title: "BotBuddy",
513
+ focus: true,
514
+ closeOnEscape: true,
515
+ });
516
+ }
517
+ async function selectDemo($) {
518
+ stopRelay();
519
+ mode = "demo";
520
+ await refresh($);
521
+ }
522
+ async function selectLive($) {
523
+ mode = "live";
524
+ selectedTicket = undefined;
525
+ await refresh($);
526
+ }
527
+ function controls($, Button) {
528
+ return [
529
+ Button({
530
+ key: "refresh",
531
+ label: loading ? "Refreshing…" : "Refresh",
532
+ hotkey: "r",
533
+ onPress: async () => {
534
+ if (!loading) await refresh($, { keep: true });
535
+ },
536
+ }),
537
+ Button({
538
+ key: "live",
539
+ label: "Live",
540
+ ...(mode !== "live" ? { plain: true } : {}),
541
+ onPress: async () => {
542
+ await selectLive($);
543
+ },
544
+ }),
545
+ Button({
546
+ key: "demo",
547
+ label: "Demo",
548
+ ...(mode !== "demo" ? { plain: true } : {}),
549
+ onPress: async () => {
550
+ await selectDemo($);
551
+ },
552
+ }),
553
+ ];
554
+ }
555
+ // --- status icons -----------------------------------------------------------
556
+ // 12px inline SVG on Desktop, drawn from the item's state the way Linear and
557
+ // GitHub draw theirs; the terminal keeps one-cell text glyphs. Fixed hex: a
558
+ // mod cannot read the theme, and these read on light and dark alike.
559
+ let surface = "desktop";
560
+ const ICON = {
561
+ grey: "#95a2b3",
562
+ yellow: "#f2c94c",
563
+ green: "#4cb782",
564
+ violet: "#5e6ad2",
565
+ orange: "#f2994a",
566
+ pr: { open: "#1a7f37", draft: "#59636e", merged: "#8250df", closed: "#cf222e" },
567
+ };
568
+ function svg(body) {
569
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="12" height="12" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
570
+ }
571
+ // A clockwise wedge from twelve o'clock covering `pct` of a radius-3.5 disc.
572
+ function wedge(pct, color) {
573
+ if (pct <= 0) return "";
574
+ if (pct >= 100) return `<circle cx="8" cy="8" r="3.5" fill="${color}"/>`;
575
+ const a = (pct / 100) * 2 * Math.PI;
576
+ const x = (8 + 3.5 * Math.sin(a)).toFixed(2);
577
+ const y = (8 - 3.5 * Math.cos(a)).toFixed(2);
578
+ return `<path d="M8 8 L8 4.5 A3.5 3.5 0 ${pct > 50 ? 1 : 0} 1 ${x} ${y} Z" fill="${color}"/>`;
579
+ }
580
+ const CHECK = `<path d="M5.2 8.2l1.9 1.9 3.7-4" stroke="#fff" stroke-width="1.6"/>`;
581
+ // The one colour of an item's state: its icon and its status word share it.
582
+ function ticketHex(status) {
583
+ if (status === "Done") return ICON.violet;
584
+ if (status === "In Progress") return ICON.yellow;
585
+ if (status === "In Review") return ICON.green;
586
+ return ICON.grey;
587
+ }
588
+ function epicHex(state) {
589
+ if (state === "completed") return ICON.violet;
590
+ if (state === "started") return ICON.yellow;
591
+ if (state === "backlog") return ICON.orange;
592
+ return ICON.grey;
593
+ }
594
+ function ticketIcon(status) {
595
+ const ring = (c, extra = "") => `<circle cx="8" cy="8" r="6" stroke="${c}"${extra}/>`;
596
+ if (status === "Done") return svg(`<circle cx="8" cy="8" r="6.5" fill="${ICON.violet}"/>` + CHECK);
597
+ if (status === "Canceled")
598
+ return svg(`<circle cx="8" cy="8" r="6.5" fill="${ICON.grey}"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4" stroke="#fff" stroke-width="1.6"/>`);
599
+ if (status === "In Progress") return svg(ring(ICON.yellow) + wedge(50, ICON.yellow));
600
+ if (status === "In Review") return svg(ring(ICON.green) + wedge(75, ICON.green));
601
+ if (status === "Todo" || status === "Triage") return svg(ring(ICON.grey));
602
+ return svg(ring(ICON.grey, ' stroke-dasharray="1.4 2.2"')); // Backlog, Unknown
603
+ }
604
+ function prIcon(state) {
605
+ const c = ICON.pr[state];
606
+ const dots = `<circle cx="4.5" cy="3.5" r="1.8" stroke="${c}"/><circle cx="4.5" cy="12.5" r="1.8" stroke="${c}"/>`;
607
+ if (state === "merged")
608
+ return svg(dots + `<circle cx="11.5" cy="8.5" r="1.8" stroke="${c}"/><path d="M4.5 5.3v5.4M4.5 5.6c0 2.4 2.4 2.9 5.2 2.9" stroke="${c}"/>`);
609
+ if (state === "draft")
610
+ return svg(dots + `<circle cx="11.5" cy="12.5" r="1.8" stroke="${c}"/><path d="M4.5 5.3v5.4M11.5 4v.6M11.5 7.2v.6" stroke="${c}"/>`);
611
+ if (state === "closed")
612
+ return svg(dots + `<circle cx="11.5" cy="12.5" r="1.8" stroke="${c}"/><path d="M4.5 5.3v5.4M10 2.5l3 3M13 2.5l-3 3M11.5 8v2.7" stroke="${c}"/>`);
613
+ return svg(dots + `<circle cx="11.5" cy="12.5" r="1.8" stroke="${c}"/><path d="M4.5 5.3v5.4M11.5 10.7V7a2 2 0 0 0-2-2H7.5M9 3.5 7.5 5 9 6.5" stroke="${c}"/>`);
614
+ }
615
+ // Linear's project hexagon, by project state (not by percent): dotted orange
616
+ // backlog, plain outline planned, a wedge of `pct` while started or paused,
617
+ // violet check completed, grey cross canceled.
618
+ const HEX = "M8 1.6 13.6 4.8v6.4L8 14.4 2.4 11.2V4.8z";
619
+ function epicIcon(state, pct) {
620
+ if (state === "completed") return svg(`<path d="${HEX}" fill="${ICON.violet}" stroke="${ICON.violet}"/>` + CHECK);
621
+ if (state === "canceled")
622
+ return svg(`<path d="${HEX}" fill="${ICON.grey}" stroke="${ICON.grey}"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4" stroke="#fff" stroke-width="1.6"/>`);
623
+ if (state === "backlog") return svg(`<path d="${HEX}" stroke="${ICON.orange}" stroke-dasharray="1.4 2"/>`);
624
+ if (state === "started") return svg(`<path d="${HEX}" stroke="${ICON.yellow}"/>` + wedge(pct, ICON.yellow));
625
+ if (state === "paused") return svg(`<path d="${HEX}" stroke="${ICON.grey}"/>` + wedge(pct, ICON.grey));
626
+ return svg(`<path d="${HEX}" stroke="${ICON.grey}"/>`); // planned
627
+ }
628
+ // The leading icon: an Svg on Desktop, else the coloured text glyph.
629
+ function lead(el, glyph, color, alt, source) {
630
+ if (surface !== "terminal" && typeof el.Svg === "function")
631
+ return el.Box({
632
+ flexDirection: "row",
633
+ flexShrink: 0,
634
+ children: [el.Svg({ alt, width: 12, height: 12, source }), el.Text({ children: [" "] })],
635
+ });
636
+ return el.Text({ color, children: [glyph + " "] });
637
+ }
638
+
639
+ // --- band: three lines, each id a link to its canonical drawer ------------
640
+ function dim(el, text) {
641
+ return el.Text({ dimColor: true, children: [text] });
642
+ }
643
+ function sep(el) {
644
+ return dim(el, " · ");
645
+ }
646
+ // Three aligned columns per line: [icon id]…, status, title. The id and
647
+ // status columns take the widest line's width on every line and never shrink,
648
+ // so ids never break and statuses line up; the title takes the rest and wraps.
649
+ // One item per row: tickets, then PRs, then the single epic.
650
+ const ID_GAP = 2;
651
+ function idsWidth(ids) {
652
+ return ids.reduce((w, id, i) => w + (i ? ID_GAP : 0) + (id.label || "").length + (id.out ? 2 : 0), 0);
653
+ }
654
+ // Icon (2 cells) then the status parts.
655
+ function statusWidth(spec) {
656
+ return (spec.icon ? 2 : 0) + spec.status.reduce((w, part, i) => w + (i ? 3 : 0) + part.text.length, 0);
657
+ }
658
+ function bandLine(el, spec, widths, right) {
659
+ const ids = [];
660
+ spec.ids.forEach((id, i) => {
661
+ if (i) ids.push(el.Text({ children: [" ".repeat(ID_GAP)] }));
662
+ if (id.label) ids.push(el.Link({ href: id.href, label: id.label }));
663
+ if (id.out) ids.push(el.Text({ children: [" "] }), el.Link({ href: id.out, label: "↗" }));
664
+ });
665
+ // The state icon sits left of the status word, in the same colour.
666
+ const status = spec.icon ? [spec.icon] : [];
667
+ spec.status.forEach((part, i) => {
668
+ if (i) status.push(sep(el));
669
+ status.push(el.Text({ color: part.color, children: [part.text] }));
670
+ });
671
+ const children = [
672
+ el.Box({ key: spec.key + "-ids", flexDirection: "row", width: widths.ids, flexShrink: 0, children: ids }),
673
+ el.Box({ key: spec.key + "-status", flexDirection: "row", width: widths.status, flexShrink: 0, children: status }),
674
+ el.Box({
675
+ key: spec.key + "-title",
676
+ flexGrow: 1,
677
+ flexShrink: 1,
678
+ minWidth: 0,
679
+ children: [el.Text({ wrap: "wrap", dimColor: spec.dim || undefined, children: [spec.title] })],
680
+ }),
681
+ ];
682
+ if (right && right.length)
683
+ children.push(el.Box({ flexDirection: "row", flexShrink: 0, columnGap: 1, children: right }));
684
+ // Top-aligned: with a wrapped title, the id and status sit on its first line.
685
+ return el.Box({ key: spec.key, flexDirection: "row", alignItems: "flex-start", columnGap: 1, children });
686
+ }
687
+ const UNREAD = {
688
+ prs: "Couldn't read this session's PRs",
689
+ epic: "Couldn't read this session's epic",
690
+ staging: "staging: couldn't read",
691
+ };
692
+ // The chip's own age format: minutes, then hours under two days, then days.
693
+ function shortAge(fromIso, now) {
694
+ const mins = Math.max(0, Math.floor((now - Date.parse(fromIso)) / 60000));
695
+ if (mins < 60) return mins + "m";
696
+ const hours = Math.floor(mins / 60);
697
+ return hours < 48 ? hours + "h" : Math.floor(hours / 24) + "d";
698
+ }
699
+ function stagingBanner(el, key, since) {
700
+ return el.Box({
701
+ key,
702
+ flexDirection: "row",
703
+ columnGap: 1,
704
+ children: [
705
+ el.Text({ color: "error", bold: true, children: ["▲ Staging red" + (since ? " " + shortAge(since, currentTime) : "")] }),
706
+ el.Link({ href: LINKS.staging, label: "Open staging ↗" }),
707
+ ],
708
+ });
709
+ }
710
+ // `inPane`: the same rows inside the detail pane, keyed apart, no Details.
711
+ function bandLines(el, $, inPane) {
712
+ const k = (key) => (inPane ? "pane-" : "") + key;
713
+ const details = inPane ? null : el.Button({
714
+ key: "details",
715
+ label: "Details",
716
+ onPress: async () => {
717
+ await openPanel($);
718
+ },
719
+ });
720
+ const tag = mode === "demo" ? [el.Text({ color: "warning", children: ["DEMO"] })] : [];
721
+ if (!snapshot) {
722
+ const text = loading ? "Loading session…" : errorText();
723
+ return [
724
+ el.Box({
725
+ key: k("line-ticket"),
726
+ flexDirection: "row",
727
+ columnGap: 1,
728
+ children: [...tag, el.Text({ color: loading ? undefined : "warning", children: ["BB · " + text] }), details].filter(Boolean),
729
+ }),
730
+ ];
731
+ }
732
+ const s = snapshot;
733
+ // One row per ticket and per PR, each with its own icon, status and title.
734
+ const specs = s.tickets.map((t) => ({
735
+ key: k("line-ticket-" + t.key),
736
+ ids: [
737
+ {
738
+ label: t.key,
739
+ href: LINKS.ticket(t.key),
740
+ out: OUT.ticket(s.tenant, t.key),
741
+ },
742
+ ],
743
+ icon: lead(el, "◆", ticketHex(t.status), "ticket " + t.status, ticketIcon(t.status)),
744
+ status: [{ text: t.status, color: ticketHex(t.status) }],
745
+ title: t.title,
746
+ }));
747
+ // BOT-3152 AC-1: no PR or no epic means no row; a failed read says so.
748
+ if (s.prs)
749
+ for (const p of s.prs) {
750
+ const status = [{ text: PR_LABEL[p.state], color: ICON.pr[p.state] }];
751
+ if (p.conflict && p.state !== "merged" && p.state !== "closed")
752
+ status.push({ text: "⚠ conflict", color: "#bc4c00" });
753
+ // BOT-3152 AC-3: the PR's code-review rounds, as get_pr_state counts them.
754
+ if (Number.isSafeInteger(p.reviews))
755
+ status.push({ text: p.reviews + (p.reviews === 1 ? " review" : " reviews"), color: ICON.grey });
756
+ specs.push({
757
+ key: k("line-pr-" + p.number),
758
+ ids: [
759
+ {
760
+ label: "#" + p.number,
761
+ href: LINKS.pr(p.repo, p.number),
762
+ out: OUT.pr(p.repo, p.number),
763
+ },
764
+ ],
765
+ icon: lead(el, "⎇", ICON.pr[p.state], "pr " + p.state, prIcon(p.state)),
766
+ status,
767
+ title: p.title,
768
+ });
769
+ }
770
+ else
771
+ specs.push({
772
+ key: k("line-pr"),
773
+ ids: [],
774
+ icon: el.Text({ color: "warning", children: ["⎇ "] }),
775
+ status: [],
776
+ title: UNREAD.prs,
777
+ dim: true,
778
+ });
779
+ if (s.epic)
780
+ specs.push({
781
+ key: k("line-epic"),
782
+ ids: [
783
+ {
784
+ // Linear's project id (P-BOT-39) when known, else the URL slug.
785
+ label: s.epic.identifier || s.epic.short_id,
786
+ href: LINKS.project(s.epic.id),
787
+ out: s.epic.url,
788
+ },
789
+ ],
790
+ icon: lead(el, "◇", epicHex(s.epic.state), "epic " + s.epic.state + " " + s.epic.percent + "%", epicIcon(s.epic.state, s.epic.percent)),
791
+ status: [{ text: s.epic.percent + "% done", color: epicHex(s.epic.state) }],
792
+ title: s.epic.name,
793
+ });
794
+ else if (s.epic_unreadable)
795
+ specs.push({ key: k("line-epic"), ids: [], icon: el.Text({ color: "warning", children: ["◇ "] }), status: [], title: UNREAD.epic, dim: true });
796
+ const widths = {
797
+ ids: Math.max(...specs.map((x) => idsWidth(x.ids))),
798
+ status: Math.max(...specs.map(statusWidth)),
799
+ };
800
+ const right = [...tag];
801
+ if (stale()) right.push(el.Text({ color: "warning", children: ["STALE"] }));
802
+ // BOT-3152 edge case: unknown staging is its own signal, never a quiet green.
803
+ if (s.staging == null) right.push(dim(el, UNREAD.staging));
804
+ if (details) right.push(details);
805
+ const lines = specs.map((spec, i) => bandLine(el, spec, widths, i === 0 ? right : null));
806
+ // BOT-3152 AC-2: the web Staging chip's red, with its link, above the rows.
807
+ if (s.staging?.red) lines.unshift(stagingBanner(el, k("line-staging"), s.staging.since));
808
+ return lines;
809
+ }
810
+ export function register(on) {
811
+ on("session.start", async ($, e, next) => {
812
+ // All background timing is local display freshness; there is no network poll loop.
813
+ if (timer) timer.cancel();
814
+ timer = $.clock.every(5000, async () => {
815
+ currentTime = await $.clock.now();
816
+ await followVisibility($);
817
+ $.ui.invalidate("ui.render");
818
+ });
819
+ await $.command.register({
820
+ name: "bb-status",
821
+ description:
822
+ "Open BB identity, Linear status and blockers (no model turn)",
823
+ argumentHint: "[live [KEY[,KEY…]]|demo]",
824
+ immediate: true,
825
+ });
826
+ parked = false;
827
+ if (await onScreen($)) await refresh($);
828
+ else parked = true;
829
+ return next(e);
830
+ });
831
+ on("command.run", { command: "bb-status" }, async ($, e) => {
832
+ const args = (e.args ?? "").trim();
833
+ if (args === "demo") await selectDemo($);
834
+ // Up to five keys, comma-separated; the adapter checks the tenant prefix.
835
+ else if (
836
+ args === "live" ||
837
+ /^live [A-Z]{2,10}-[1-9]\d{0,5}(?:,[A-Z]{2,10}-[1-9]\d{0,5}){0,4}$/.test(args)
838
+ ) {
839
+ mode = "live";
840
+ selectedTicket = args.split(" ")[1];
841
+ await refresh($);
842
+ } else if (args === "") {
843
+ if (!loading) await refresh($, { keep: true });
844
+ } else {
845
+ stopRelay();
846
+ errorCode = "ticket_missing";
847
+ snapshot = null;
848
+ keptLive = false;
849
+ generation++;
850
+ loading = false;
851
+ $.ui.invalidate("ui.render");
852
+ }
853
+ await openPanel($);
854
+ return {};
855
+ });
856
+ on("turn.start", async ($, e, next) => {
857
+ if (!e.agentId) {
858
+ activity = "Working";
859
+ $.ui.invalidate("ui.render");
860
+ }
861
+ return next(e);
862
+ });
863
+ on("turn.complete", async ($, e, next) => {
864
+ if (!e.agentId) {
865
+ activity = e.isAborted ? "Stopped" : "Idle";
866
+ $.ui.invalidate("ui.render");
867
+ }
868
+ return next(e);
869
+ });
870
+ on("session.end", async ($, e, next) => {
871
+ stopRelay();
872
+ generation++;
873
+ snapshot = null;
874
+ keptLive = false;
875
+ selectedTicket = undefined;
876
+ mode = "live";
877
+ loading = false;
878
+ errorCode = "session_changed";
879
+ parked = false;
880
+ unbound = false;
881
+ if (timer) timer.cancel();
882
+ return next(e);
883
+ });
884
+ on(
885
+ "classic.SessionStart",
886
+ { source: ["clear", "resume", "fork"] },
887
+ async ($, e, next) => {
888
+ selectedTicket = undefined;
889
+ mode = "live";
890
+ await refresh($);
891
+ return next(e);
892
+ },
893
+ );
894
+ on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
895
+ const el = $.ui.resolve(e);
896
+ surface = e.surface;
897
+ const theirs = await next(e);
898
+ if (unbound && mode === "live") return theirs;
899
+ return el.Box({
900
+ flexDirection: "column",
901
+ children: [theirs, ...bandLines(el, $)],
902
+ });
903
+ });
904
+ on("ui.render", { component: "Pane" }, async ($, e, next) => {
905
+ if (e.requestId !== PANE) return next(e);
906
+ const { Box, Text, Button, Link } = $.ui.resolve(e);
907
+ const children = [
908
+ Text({ bold: true, color: "cyan", children: ["BOTBUDDY · SESSION"] }),
909
+ Text({
910
+ dimColor: true,
911
+ children: [
912
+ mode === "demo"
913
+ ? "DEMO · illustrative data"
914
+ : "LIVE",
915
+ ],
916
+ }),
917
+ ];
918
+ if (loading && !snapshot) children.push(Text({ children: ["Loading session…"] }));
919
+ else if (!snapshot)
920
+ children.push(Text({ color: "yellow", children: [errorText()] }));
921
+ else {
922
+ children.push(
923
+ Text({ bold: true, children: [snapshot.agent.name] }),
924
+ Text({
925
+ children: [snapshot.ticket.key + " · " + snapshot.ticket.title],
926
+ }),
927
+ Text({
928
+ color: "cyan",
929
+ // BotBuddy status is read from Linear; SupplyGuard's from BB's copy.
930
+ children: [
931
+ (snapshot.ticket.url ? "Linear · " : "BB · ") +
932
+ snapshot.ticket.status,
933
+ ],
934
+ }),
935
+ Text({ children: ["Agent · " + activity] }),
936
+ Text({ dimColor: true, children: [snapshot.branch] }),
937
+ );
938
+ children.push(
939
+ Text({
940
+ color: stale() ? "yellow" : undefined,
941
+ children: [
942
+ stale()
943
+ ? staleText()
944
+ : (relayArmed || keptLive ? "Live · " : "Observed · ") + snapshot.observed_at,
945
+ ],
946
+ }),
947
+ );
948
+ // The same three items the band shows, one row each in full.
949
+ surface = e.surface;
950
+ children.push(...bandLines($.ui.resolve(e), $, true));
951
+ if (snapshot.blockers === null)
952
+ children.push(
953
+ Text({ dimColor: true, children: ["Blockers are read from the BotBuddy Linear workspace only."] }),
954
+ );
955
+ else
956
+ children.push(
957
+ Text({
958
+ bold: true,
959
+ children: ["Blocking tickets · " + snapshot.blockers.length],
960
+ }),
961
+ );
962
+ for (const blocker of snapshot.blockers || []) {
963
+ const label =
964
+ blocker.key + " · " + blocker.title + " · " + blocker.status;
965
+ children.push(
966
+ mode === "demo"
967
+ ? Text({ children: [label + " (example)"] })
968
+ : Link({ label, href: blocker.url }),
969
+ );
970
+ }
971
+ if (snapshot.blockers && snapshot.blockers.length === 0)
972
+ children.push(
973
+ Text({ children: ["No open blockers at the last refresh."] }),
974
+ );
975
+ const links = [
976
+ Link({ label: "Open ticket ↗", href: LINKS.ticket(snapshot.ticket.key) }),
977
+ ];
978
+ if (snapshot.ticket.url)
979
+ links.push(Link({ label: "Linear ↗", href: snapshot.ticket.url }));
980
+ links.push(Link({ label: "Open BB ↗", href: APP }));
981
+ children.push(Box({ flexDirection: "row", columnGap: 2, children: links }));
982
+ }
983
+ children.push(
984
+ Box({
985
+ flexDirection: "row",
986
+ columnGap: 2,
987
+ children: controls($, Button),
988
+ }),
989
+ );
990
+ return Box({ flexDirection: "column", rowGap: 1, padding: 1, children });
991
+ });
992
+ }