@bongos/core 1.24.95 → 1.24.96

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.
@@ -429,6 +429,53 @@ async function removeRunner({ builderId, host }) {
429
429
  return r ? { host: r.host, session_id: r.session_id ? String(r.session_id) : null } : null;
430
430
  }
431
431
 
432
+ // ── one goal, seen from the builder's runner (task 1005243) ──────────────────
433
+ //
434
+ // readRunnerGoalStats — what the game's runner card says about ONE goal: how
435
+ // many tasks there the builder's runner has shipped, the tokens those sessions
436
+ // spent, their average length, and how many tasks are still to build. All of it
437
+ // is the ledger's answer: a runner's claim carries `autonomous` (set by the
438
+ // server from the claiming session, core_295), a shipped claim is outcome
439
+ // 'shipped', and a session's tokens and length are what ship.js uploaded to
440
+ // session_records. SCOPED TO ONE BUILDER, like the heartbeat read: nobody reads
441
+ // another builder's runner.
442
+ //
443
+ // ITS COST. `mine` starts from the caller's claims through core_263's
444
+ // idx_claims_builder_history (builder_id, claimed_at), so it reads one builder's
445
+ // claims, never the table; session_records goes through idx_session_records_builder,
446
+ // and the tasks count through the goal's own rows. The card asks only while it is
447
+ // open (home-runner.js, every 15 s while the tab is visible).
448
+ async function readRunnerGoalStats(builderId, goalId) {
449
+ const { rows } = await pool.query(
450
+ `WITH mine AS (
451
+ SELECT DISTINCT c.task_id
452
+ FROM claims c JOIN tasks t ON t.id = c.task_id
453
+ WHERE c.builder_id = $1 AND c.autonomous AND c.outcome = 'shipped' AND t.goal_id = $2
454
+ ), spent AS (
455
+ SELECT COALESCE(SUM(sr.total_tokens), 0)::bigint AS tokens,
456
+ AVG(sr.duration_seconds) FILTER (WHERE sr.duration_seconds > 0) AS avg_s
457
+ FROM session_records sr
458
+ WHERE sr.builder_id = $1 AND sr.task_ids && ARRAY(SELECT task_id FROM mine)
459
+ )
460
+ SELECT (SELECT COUNT(*) FROM mine)::int AS tasks_done,
461
+ spent.tokens, spent.avg_s,
462
+ -- what is left is told only for a goal the project opened to runners, so
463
+ -- the read is no window onto a goal's size the caller could not send to
464
+ CASE WHEN EXISTS (SELECT 1 FROM autonomy_allowed_goals WHERE goal_id = $2)
465
+ THEN (SELECT COUNT(*) FROM tasks WHERE goal_id = $2 AND status IN ('ready', 'active'))::int END AS tasks_left
466
+ FROM spent`,
467
+ [builderId, goalId]
468
+ );
469
+ const r = rows[0] || {};
470
+ const avg = r.avg_s == null ? null : Math.round(Number(r.avg_s));
471
+ return {
472
+ tasks_done: Number(r.tasks_done) || 0,
473
+ tokens: Number(r.tokens) || 0,
474
+ avg_task_seconds: Number.isFinite(avg) && avg > 0 ? avg : null,
475
+ tasks_left: r.tasks_left == null ? null : Number(r.tasks_left) || 0,
476
+ };
477
+ }
478
+
432
479
  module.exports = {
433
480
  recordAutonomyRun,
434
481
  lastAutonomyHead,
@@ -449,4 +496,5 @@ module.exports = {
449
496
  setBuilderGoals,
450
497
  setBuilderPause,
451
498
  readAllRunnerScopes,
499
+ readRunnerGoalStats,
452
500
  };
@@ -376,6 +376,21 @@ module.exports = function buildAutonomyRouter() {
376
376
  }
377
377
  });
378
378
 
379
+ // ONE GOAL, SEEN FROM THE CALLER'S RUNNER (task 1005243): the game's runner
380
+ // card on Home — tasks it shipped there, the tokens they took, the average
381
+ // task length and what is left, so the card can say roughly how long the rest
382
+ // takes. Read-only and the caller's own, behind `autonomy.run` like the picks.
383
+ router.get('/autonomy/me/goals/:goalId/stats', auth.requireBuilder, auth.requirePermission('autonomy.run'), async (req, res) => {
384
+ const goalId = Number(req.params.goalId);
385
+ if (!Number.isSafeInteger(goalId) || goalId <= 0) return res.fail('goal_id_invalid', { status: 400, message: 'goal id must be a positive integer' });
386
+ try {
387
+ res.json({ goal_id: goalId, ...(await db.readRunnerGoalStats(req.builder.id, goalId)) });
388
+ } catch (err) {
389
+ log.error('[gds] GET /autonomy/me/goals/:goalId/stats', err);
390
+ res.fail('runner_stats_unreadable', { status: 503, message: 'could not read the runner stats' });
391
+ }
392
+ });
393
+
379
394
  // The owner pauses or resumes ONE builder's runner without throwing the master
380
395
  // switch. Same atom as the kill switch: stopping a machine is the same act
381
396
  // whether it is everyone's or one person's, and the paused builder must not be
@@ -128,7 +128,7 @@ const PERMISSIONS = [
128
128
  // "runner operators" should be able to compose one. It can never widen the fence
129
129
  // — the project allowlist and the kill switch stay behind autonomy.fence.manage —
130
130
  // so delegating it delegates only "whose machine may work the allowed goals".
131
- { key: 'autonomy.run', system: false, floor: 'metic', guards: 'PUT /autonomy/me/goals (a builder\'s own runner scope); also decides whether GET /autonomy/fence answers enabled for the caller' },
131
+ { key: 'autonomy.run', system: false, floor: 'metic', guards: 'PUT /autonomy/me/goals (a builder\'s own runner scope) and GET /autonomy/me/goals/:goalId/stats (its numbers on one goal); also decides whether GET /autonomy/fence answers enabled for the caller' },
132
132
  // BV1.R21 (task 1003608, goal 1000086). ADR 0250 §6 amends ADR 0157 for exactly
133
133
  // this key: `version.create` returns to the Archon floor.
134
134
  //
@@ -297,6 +297,8 @@
297
297
  const a = it.a, x0 = a.x, y0 = a.y, x1 = x0 + FOOT, y1 = y0 + FOOT, base = 4;
298
298
  const d = doneAt(a, t), built = Math.max(2, (d.build + d.art) * m.px), clad = d.art * m.px;
299
299
  const site = sites[a.id], who = site ? m.people[site.builder] : null, agent = !!(site && (site.agent || (who && who.agent)));
300
+ // your runner sent here (task 1005243): its marker stands in for the agent's face
301
+ const mine = latest && o.runner && o.runner.goal === a.id ? o.runner.state : null;
300
302
  const wait = latest ? (m.waiting[a.id] || 0) : 0;
301
303
  const g = [];
302
304
  g.push(...box(x0 - 0.12, y0 - 0.12, x1 + 0.12, y1 + 0.12, 0, base, I.plinth));
@@ -330,12 +332,13 @@
330
332
  const hx = mx + 1.8;
331
333
  g.push(line(P(hx, my, ct), P(hx, my, top - 6), I.steel, 1));
332
334
  g.push(node('g', { class: 'hc-slab' }, box(hx - 0.3, my - 0.2, hx + 0.3, my + 0.2, top - 38, 3, I.slab)));
333
- if (who) over.push({ k: 'face', p: P(mx + 0.9, my, ct + 30), who, agent, a });
335
+ if (who && !mine) over.push({ k: 'face', p: P(mx + 0.9, my, ct + 30), who, agent, a });
334
336
  }
337
+ if (mine) over.push({ k: 'runner', p: site ? P(x0 + 1.4, y0 + 0.5, top + 86) : P((x0 + x1) / 2, (y0 + y1) / 2, top + 40), state: mine, a });
335
338
  if (wait) over.push({ k: 'dot', p: P((x0 + x1) / 2, (y0 + y1) / 2, top + (site ? 12 : 22)), a, n: wait });
336
339
  const shipped = Math.round(d.build + d.art), byArt = Math.round(d.art);
337
340
  const tip = `${shipped} shipped${byArt ? ` · ${byArt} by artists` : ''}${who ? ` · ${agent ? `${who.name}'s agent` : who.name} on site` : ''} · click to open the goal`;
338
- out.push(node('a', { href: `/goals#/goal/${encodeURIComponent(a.id)}`, class: 'hc-tower', 'aria-label': `${a.title}: ${shipped} shipped. Open the goal.`, 'data-tip': a.title, 'data-tip-body': tip }, g));
341
+ out.push(node('a', { href: `/goals#/goal/${encodeURIComponent(a.id)}`, class: 'hc-tower' + (o.picked === a.id ? ' hc-tower--picked' : ''), 'data-goal': a.id, 'aria-label': `${a.title}: ${shipped} shipped. Open the goal.`, 'data-tip': a.title, 'data-tip-body': tip }, g));
339
342
  // the cloud at its foot, over this tower and under the next one forward
340
343
  if (cloudy) out.push(puff(x1 - 0.5, y1 - 0.5, 62));
341
344
  });
@@ -344,6 +347,7 @@
344
347
 
345
348
  over.forEach((v, n) => {
346
349
  if (v.k === 'face') { out.push(avatarNode(v.p, v.who, v.agent, v.a, I, n, node, line, circle)); return; }
350
+ if (v.k === 'runner') { out.push(runnerNode(v.p, v.state, v.a, I, node, line, circle)); return; }
347
351
  const cx = v.p[0] - 30, cy = v.p[1];
348
352
  out.push(node('g', { class: 'hc-wait', 'data-tip': v.a.title, 'data-tip-body': `${v.n} waiting on you` }, [
349
353
  circle(cx, cy, 11, { fill: 'var(--accent)', stroke: 'var(--bg-card)', 'stroke-width': 2.5 }),
@@ -607,6 +611,24 @@
607
611
  return node('g', { class: 'hc-face-pin', 'data-tip': agent ? `${who.name}'s agent` : who.name, 'data-tip-body': a.title }, g);
608
612
  }
609
613
 
614
+ // Your Autobongos runner on the tower it was sent to (task 1005243): the agent's robot face,
615
+ // ringed in the state it is in. Working wears the accent ring that turns (CSS, under the
616
+ // Kill Switch); resting a dashed ring; starting the info ring; ready a soft ring; off no ring
617
+ // and a faded face. The words for each are the visor's card, never this mark alone.
618
+ // The same words the visor's card puts on the device's face (runner-button.js buttonState).
619
+ const RUNNER_WORDS = { working: 'Running', resting: 'Resting', starting: 'Starting…', ready: 'Ready', off: 'Recalled' };
620
+ function runnerNode(p, state, a, I, node, line, circle) {
621
+ const x = p[0], y = p[1];
622
+ const st = RUNNER_WORDS[state] ? state : 'off';
623
+ return node('g', { class: `hc-runner hc-runner--${st}`, 'data-goal': a.id, 'data-tip': 'Your runner', 'data-tip-body': `${RUNNER_WORDS[st]} · ${a.title}` }, [
624
+ line([x, y + 18], [x, y + 32], I.steel, 1),
625
+ circle(x, y, 21, { class: 'hc-runner__ring', fill: 'none', 'stroke-width': 2.5 }),
626
+ circle(x, y, 16, { class: 'hc-runner__face', fill: I.avAgent, stroke: 'var(--bg-card)', 'stroke-width': 3 }),
627
+ node('rect', { class: 'hc-runner__ink', x: r1(x - 7), y: r1(y - 5), width: 14, height: 11, rx: 3, fill: 'none', stroke: I.avInk, 'stroke-width': 1.5 }),
628
+ circle(x - 3, y + 0.5, 1.5, { class: 'hc-runner__eye', fill: I.avInk }), circle(x + 3, y + 0.5, 1.5, { class: 'hc-runner__eye', fill: I.avInk }),
629
+ ]);
630
+ }
631
+
610
632
  function initial(name) { const s = String(name || '').trim(); return s ? s[0].toUpperCase() : '?'; }
611
633
  // a stable spread for an idea's place in the sky (FNV-1a over its id)
612
634
  function hash(s) { let h = 2166136261; for (const c of String(s)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return Math.abs(h); }
@@ -38,6 +38,20 @@ html[data-game="on"] body[data-page="home"] .page-head--hall .page-head__h { wid
38
38
  .hc-svg a:focus { outline: none; }
39
39
  .hc-svg a:focus-visible .hc-face { filter: brightness(1.35); }
40
40
  .hc-svg .hc-tower:hover .hc-face { filter: brightness(1.3); }
41
+ /* game mode, your runner (task 1005243): the tower picked for the visor's card is lit and edged
42
+ in the accent; your runner's marker is the agent's face in a ring that says its state */
43
+ .hc-svg .hc-tower--picked .hc-face { filter: brightness(1.2); stroke: var(--accent); stroke-width: 2px; }
44
+ .hc-svg .hc-runner { cursor: pointer; }
45
+ .hc-svg .hc-runner__ring { stroke: var(--ink-soft); stroke-opacity: .5; stroke-width: 3.5px; }
46
+ /* yours, not anyone's agent: the accent face, the dark ink on it */
47
+ .hc-svg .hc-runner__face { fill: var(--accent); }
48
+ .hc-svg .hc-runner__ink { stroke: var(--accent-on); }
49
+ .hc-svg .hc-runner__eye { fill: var(--accent-on); }
50
+ .hc-svg .hc-runner--working .hc-runner__ring { stroke: var(--accent); stroke-opacity: 1; stroke-dasharray: 22 10; transform-box: fill-box; transform-origin: center; animation: hc-turn 6s linear infinite; }
51
+ .hc-svg .hc-runner--starting .hc-runner__ring { stroke: var(--info); stroke-opacity: 1; stroke-dasharray: 4 6; }
52
+ .hc-svg .hc-runner--resting .hc-runner__ring { stroke: var(--info); stroke-opacity: .9; stroke-dasharray: 2 5; }
53
+ .hc-svg .hc-runner--off { opacity: .55; }
54
+ .hc-svg .hc-runner--off .hc-runner__ring { stroke-opacity: 0; }
41
55
  /* the core and the museum stay quiet on the builder's Home so the new buildings lead;
42
56
  each comes back to full colour under the pointer or the keyboard */
43
57
  .hc-svg .hc-quiet { opacity: .62; filter: saturate(.3); transition: opacity .3s, filter .3s; }
@@ -110,6 +124,7 @@ html[data-theme="dark"] .home-city.hc-is-sky { background: linear-gradient(color
110
124
 
111
125
  /* live, and nothing more: a crane's slab climbs, this week's ideas glow (20s+) */
112
126
  @keyframes hc-lift { 0%, 12% { transform: translateY(0); } 55%, 70% { transform: translateY(-24px); } 100% { transform: translateY(0); } }
127
+ @keyframes hc-turn { to { transform: rotate(360deg); } }
113
128
  @keyframes hc-glow { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
114
129
  @keyframes hc-live { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); } 100% { box-shadow: 0 0 0 8px transparent; } }
115
130
  .hc-slab { animation: hc-lift 20s ease-in-out infinite; }
@@ -104,7 +104,8 @@
104
104
  const svg = host.querySelector('.hc-svg');
105
105
  const sky = craft() === 'ideating';
106
106
  const draw = sky ? Draw.skySvg : Draw.citySvg;
107
- const out = draw(model, atLive(t) ? model.W - 1 : t, { mode: mode(), gov: window.GovCity || null });
107
+ const R = window.OTBHomeRunner; // your runner on its tower, and the tower picked for it (home-runner.js)
108
+ const out = draw(model, atLive(t) ? model.W - 1 : t, { mode: mode(), gov: window.GovCity || null, runner: R ? R.marker() : null, picked: R ? R.picked() : null });
108
109
  host.classList.toggle('hc-is-sky', sky);
109
110
  svg.setAttribute('aria-label', sky ? SKY_LABEL : CITY_LABEL);
110
111
  host.querySelector('#home-city-h').textContent = sky ? 'The project\'s ideas this week' : 'The project this week';
@@ -354,6 +355,7 @@
354
355
  new MutationObserver(() => { if (model) drawScene(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
355
356
  document.addEventListener('otb:craft', () => { if (model) { drawScene(); drawLadder(); } });
356
357
  window.addEventListener('resize', () => { if (model) drawLadder(); });
358
+ document.addEventListener('otb:home-runner', () => { if (model) drawScene(); });
357
359
  registerScene();
358
360
  load();
359
361
  }
@@ -0,0 +1,248 @@
1
+ // modules/hall-ui/public/home-runner.js — your Autobongos runner on Home's city
2
+ // (task 1005243, goal 1000142).
3
+ //
4
+ // WHY. Owner direction, 2026-11-12: running your runner should feel like putting a
5
+ // builder on a building in a base-building game. In game mode you tap a tower (a
6
+ // goal), the visor offers "Send runner here", and the tower then carries your
7
+ // runner's marker; tap it again and the visor shows what it has done there and
8
+ // "Recall runner". The owner chose that a runner works ONE tower at a time, so
9
+ // sending it sets your goal picks to that one goal.
10
+ //
11
+ // THE RULES IT KEEPS.
12
+ // - ADR 0403: the scene is the view and the visor is the control. The tower only
13
+ // gets picked; every button is on the glass (OTBVisor.focus).
14
+ // - The server decides who may run a runner: GET /autonomy/fence answers
15
+ // scope.may_run, and only then is a tower pickable. Signed out, a rank without
16
+ // `autonomy.run`, or classic mode: towers stay plain links and nothing is new.
17
+ // - Every write is a route Settings already uses: PUT /autonomy/me/goals and
18
+ // POST /autonomy/runners/:host/run. The numbers come from GET
19
+ // /autonomy/me/goals/:id/stats (the ledger: runner claims shipped there, the
20
+ // tokens their sessions took, the tasks still ready).
21
+ // - The device's face (working, resting until, off…) is runner-button.js's
22
+ // buttonState, the same decision the Settings card draws.
23
+ //
24
+ // The decision (cardFor, stateOf) is PURE and exported for node tests; the wiring
25
+ // below it only runs in a browser.
26
+ (function (root) {
27
+ 'use strict';
28
+
29
+ const RB = () => (root && root.OTBRunnerButton) || (typeof require === 'function' ? require('./runner-button.js') : null);
30
+
31
+ // ---------- pure ----------
32
+
33
+ // The device that speaks for "your runner": one that is working, else one that is
34
+ // switched on, else the most recently seen (the server's order).
35
+ function leadDevice(runners) {
36
+ const list = Array.isArray(runners) ? runners.filter(Boolean) : [];
37
+ return list.find((r) => r.health && r.health.workingTaskId) ||
38
+ list.find((r) => r.run_requested !== false) || list[0] || null;
39
+ }
40
+ function anyOn(runners) { return (Array.isArray(runners) ? runners : []).some((r) => r && r.run_requested !== false); }
41
+
42
+ // The goal the runner is sent to: exactly one pick. Several picks (made in Settings)
43
+ // is not one tower, so no tower carries the marker until the builder sends it.
44
+ function assignedGoal(fence) {
45
+ const g = fence && fence.scope && Array.isArray(fence.scope.goals) ? fence.scope.goals : [];
46
+ return g.length === 1 ? String(g[0]) : null;
47
+ }
48
+
49
+ // The marker's state, from the device's face: working | resting | starting | ready | off.
50
+ function stateOf({ runners, fence, nowMs } = {}) {
51
+ const d = leadDevice(runners);
52
+ if (!d || !anyOn(runners)) return 'off';
53
+ const b = RB().buttonState({ runner: d, fence, nowMs });
54
+ if (d.health && d.health.workingTaskId && b.status === 'Running') return 'working';
55
+ if (/^Resting/.test(b.status)) return 'resting';
56
+ if (b.status === 'Starting…' || b.status === 'Updating…') return 'starting';
57
+ if (b.status === 'Running' || b.status === 'Ready') return 'ready';
58
+ return 'off';
59
+ }
60
+
61
+ // 1.2M tokens, 340k tokens, 900 tokens.
62
+ function tokensText(n) {
63
+ const v = Number(n) || 0;
64
+ if (v >= 1e6) return `${(v / 1e6).toFixed(v >= 1e7 ? 0 : 1).replace(/\.0$/, '')}M tokens`;
65
+ if (v >= 1e3) return `${Math.round(v / 1e3)}k tokens`;
66
+ return `${Math.round(v)} tokens`;
67
+ }
68
+ // "about 40 min", "about 3 h", "about 2 days": an estimate, so rounded like one.
69
+ function aboutText(seconds) {
70
+ const s = Number(seconds);
71
+ if (!Number.isFinite(s) || s <= 0) return '';
72
+ const min = s / 60;
73
+ if (min < 60) return `about ${Math.max(5, Math.round(min / 5) * 5)} min`;
74
+ const h = min / 60;
75
+ if (h < 36) return `about ${Math.round(h)} h`;
76
+ return `about ${Math.round(h / 24)} days`;
77
+ }
78
+ function plural(n, one, many) { return `${n} ${n === 1 ? one : many}`; }
79
+
80
+ // What the visor's card says for one tower. Returns null when the viewer has no
81
+ // runner to send (the tower then stays a plain link), else
82
+ // { title, status: { text, tone }, lines: [string], lead: { label, act } | null }
83
+ // act: 'send' | 'recall' | 'setup' | 'open'.
84
+ function cardFor({ goal, runners, fence, stats, nowMs = Date.now(), titleOf } = {}) {
85
+ if (!goal || !fence || !fence.scope || fence.scope.may_run !== true) return null;
86
+ const id = String(goal.id);
87
+ const title = goal.title || `Goal ${id}`;
88
+ const nameOf = (g) => (typeof titleOf === 'function' && titleOf(g)) || `goal ${g}`;
89
+ const list = Array.isArray(runners) ? runners : [];
90
+ const st = stats || null;
91
+ const done = st ? Number(st.tasks_done) || 0 : 0;
92
+ const left = st ? Number(st.tasks_left) || 0 : null;
93
+ const eta = st && left ? aboutText(left * (Number(st.avg_task_seconds) || 0)) : '';
94
+ const leftLine = left == null ? null : left === 0 ? 'Nothing ready to build here right now.' : `${plural(left, 'task', 'tasks')} ready${eta ? ` · ${eta} for your runner` : ''}`;
95
+ const doneLine = st && done ? `${plural(done, 'task', 'tasks')} done here · ${tokensText(st.tokens)}` : null;
96
+
97
+ if (!list.length) {
98
+ return { title, status: { text: 'No runner yet', tone: 'neutral' },
99
+ lines: ['Set it up on your PC once, then send it to any tower.'], lead: { label: 'Set up a runner', act: 'setup' } };
100
+ }
101
+ const allowed = fence.project && Array.isArray(fence.project.goals) ? fence.project.goals.map(String) : [];
102
+ if (!allowed.includes(id)) {
103
+ return { title, status: { text: 'Closed to runners', tone: 'neutral' },
104
+ lines: ['The project has not opened this goal to runners yet.'], lead: { label: 'Open the goal', act: 'open' } };
105
+ }
106
+ const here = assignedGoal(fence) === id;
107
+ const on = anyOn(list);
108
+ if (here && on) {
109
+ const b = RB().buttonState({ runner: leadDevice(list), fence, nowMs });
110
+ const face = b.status ? `${b.status}${b.countdown ? ` · ${b.countdown}` : ''}` : 'Off';
111
+ return { title, status: { text: face, tone: b.tone || 'neutral' },
112
+ lines: [b.sub, doneLine, leftLine].filter(Boolean), lead: { label: 'Recall runner', act: 'recall' } };
113
+ }
114
+ const where = on && assignedGoal(fence) ? `Your runner is at ${nameOf(assignedGoal(fence))}` :
115
+ on ? 'Your runner is working on several goals' : 'Your runner is resting';
116
+ return { title, status: { text: here ? 'Recalled' : where, tone: 'neutral' },
117
+ lines: [doneLine, leftLine].filter(Boolean), lead: { label: 'Send runner here', act: 'send' } };
118
+ }
119
+
120
+ const api = { cardFor, stateOf, assignedGoal, leadDevice, tokensText, aboutText };
121
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
122
+ if (!root || typeof document === 'undefined') return;
123
+ root.OTBHomeRunnerLib = api;
124
+
125
+ // ---------- the wiring (browser only) ----------
126
+ const API = '/api/bongos';
127
+ const POLL_MS = 15000;
128
+ let fence = null, runners = [], picked = null, stats = null, busy = false, error = '', timer = null;
129
+ let client = null;
130
+ function http() {
131
+ if (!client && root.BongosClient) client = root.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
132
+ return client;
133
+ }
134
+ async function req(method, path, body) {
135
+ const c = http();
136
+ if (!c) return null;
137
+ try { return await c.request(method, API + path, body ? { body } : undefined); } catch (_) { return null; }
138
+ }
139
+ function game() { return document.documentElement.dataset.game === 'on'; }
140
+ function mayRun() { return !!(fence && fence.scope && fence.scope.may_run === true); }
141
+ function active() { return game() && mayRun() && !!root.OTBVisor && typeof root.OTBVisor.focus === 'function'; }
142
+ function towerOf(id) { return document.querySelector(`.home-city .hc-tower[data-goal="${CSS.escape(String(id))}"]`); }
143
+ function titleOf(id) { const t = towerOf(id); return t ? t.getAttribute('data-tip') : null; }
144
+
145
+ // What home-city.js draws: the marker on the assigned tower, and the picked tower.
146
+ function marker() {
147
+ if (!active()) return null;
148
+ const goal = assignedGoal(fence);
149
+ return goal ? { goal, state: stateOf({ runners, fence, nowMs: Date.now() }) } : null;
150
+ }
151
+ function changed() { document.dispatchEvent(new CustomEvent('otb:home-runner')); }
152
+
153
+ async function readState() {
154
+ const [f, r] = await Promise.all([req('GET', '/autonomy/fence'), req('GET', '/autonomy/runners')]);
155
+ fence = f && f.ok && f.data ? f.data : null;
156
+ runners = r && r.ok && r.data && Array.isArray(r.data.runners) ? r.data.runners : [];
157
+ }
158
+ async function readStats() {
159
+ if (!picked) return;
160
+ const s = await req('GET', `/autonomy/me/goals/${encodeURIComponent(picked)}/stats`);
161
+ stats = s && s.ok && s.data ? s.data : null;
162
+ }
163
+
164
+ function show() {
165
+ const V = root.OTBVisor;
166
+ if (!V || typeof V.focus !== 'function') return;
167
+ if (!picked || !active()) { V.focus(null); return; }
168
+ const card = cardFor({ goal: { id: picked, title: titleOf(picked) }, runners, fence, stats, nowMs: Date.now(), titleOf });
169
+ if (!card) { V.focus(null); return; }
170
+ const lead = card.lead ? { label: busy ? (card.lead.act === 'recall' ? 'Recalling…' : 'Sending…') : card.lead.label, busy, run: () => act(card.lead.act) } : null;
171
+ V.focus({ key: `runner:${picked}`, title: card.title, status: card.status, lines: error ? [error].concat(card.lines) : card.lines, lead, onClose: unpick });
172
+ }
173
+
174
+ async function act(what) {
175
+ if (busy || !picked) return;
176
+ if (what === 'open') { root.location.assign(`/goals#/goal/${encodeURIComponent(picked)}`); return; }
177
+ if (what === 'setup') { root.location.assign('/settings#autobongos'); return; }
178
+ busy = true; error = ''; show();
179
+ let ok = true;
180
+ if (what === 'send') {
181
+ const g = await req('PUT', '/autonomy/me/goals', { goal_ids: [Number(picked)] });
182
+ ok = !!(g && g.ok);
183
+ if (ok) {
184
+ const outs = await Promise.all(runners.filter((r) => r.run_requested === false)
185
+ .map((r) => req('POST', `/autonomy/runners/${encodeURIComponent(r.host)}/run`, { run: true })));
186
+ ok = outs.every((o) => o && o.ok);
187
+ }
188
+ } else if (what === 'recall') {
189
+ const outs = await Promise.all(runners.filter((r) => r.run_requested !== false)
190
+ .map((r) => req('POST', `/autonomy/runners/${encodeURIComponent(r.host)}/run`, { run: false })));
191
+ ok = outs.every((o) => o && o.ok);
192
+ }
193
+ if (!ok) error = what === 'send' ? 'Could not send your runner. Try again.' : 'Could not recall your runner. Try again.';
194
+ await readState();
195
+ await readStats();
196
+ busy = false;
197
+ show();
198
+ changed();
199
+ }
200
+
201
+ function pick(id) {
202
+ picked = String(id); stats = null; error = '';
203
+ show(); changed();
204
+ readStats().then(() => { if (picked === String(id)) show(); });
205
+ }
206
+ // The visor's card closed (Escape, its close mark, or a tap on open ground).
207
+ function unpick() {
208
+ if (!picked) return;
209
+ picked = null; stats = null; error = '';
210
+ changed();
211
+ }
212
+
213
+ // A tap on a tower in game mode picks it instead of leaving the page; a tap on open
214
+ // ground lets the pick go. Capture, so it runs before the tower's own link.
215
+ function onClick(e) {
216
+ if (!active()) return;
217
+ const city = e.target.closest && e.target.closest('.home-city .hc-svg');
218
+ if (!city) return;
219
+ const t = e.target.closest('.hc-tower[data-goal], .hc-runner[data-goal]');
220
+ if (!t) { if (picked && !e.target.closest('a')) { root.OTBVisor.focus(null); unpick(); } return; }
221
+ e.preventDefault();
222
+ const id = t.getAttribute('data-goal');
223
+ if (picked === id) return;
224
+ pick(id);
225
+ }
226
+
227
+ async function refresh() {
228
+ await readState();
229
+ if (picked) await readStats();
230
+ if (picked) show();
231
+ changed();
232
+ }
233
+ function poll() {
234
+ clearTimeout(timer);
235
+ timer = setTimeout(async () => {
236
+ if (!document.hidden && game() && mayRun() && runners.length) await refresh();
237
+ poll();
238
+ }, POLL_MS);
239
+ }
240
+
241
+ function start() {
242
+ document.addEventListener('click', onClick, true);
243
+ refresh().then(poll);
244
+ }
245
+ root.OTBHomeRunner = { marker, picked: () => (active() ? picked : null), refresh };
246
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
247
+ else start();
248
+ })(typeof window !== 'undefined' ? window : null);
@@ -19,11 +19,11 @@
19
19
  <link rel="preconnect" href="https://fonts.googleapis.com">
20
20
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
21
  <link rel="stylesheet" href="/builders/style.css?v=2026-11-11-helm4d">
22
- <link rel="stylesheet" href="/builders/visor.css?v=2026-11-11-helm4c">
22
+ <link rel="stylesheet" href="/builders/visor.css?v=2026-11-12-runner">
23
23
  <link rel="stylesheet" href="/builders/welcome-boot.css?v=2026-11-07-brand-mark">
24
24
  <link rel="stylesheet" href="/builders/shell-game.css?v=2026-11-11-view2">
25
25
  <link rel="stylesheet" href="/builders/hall-ask.css">
26
- <link rel="stylesheet" href="/builders/home-city.css?v=2026-11-11-helm4c">
26
+ <link rel="stylesheet" href="/builders/home-city.css?v=2026-11-12-runner">
27
27
  <link rel="stylesheet" href="/builders/home-gallery.css">
28
28
  <link rel="stylesheet" href="/builders/home-idea-box.css">
29
29
  </head>
@@ -116,7 +116,7 @@
116
116
  <script src="/builders/shell.js?v=2026-11-11-view2" defer></script>
117
117
  <script src="/builders/hall-dialog.js" defer></script>
118
118
  <!-- task 1005148: the visor (glass over the scene, game mode only; ADR 0399), after the shell and hall-dialog.js -->
119
- <script src="/builders/visor.js?v=2026-11-11-helm3" defer></script>
119
+ <script src="/builders/visor.js?v=2026-11-12-runner" defer></script>
120
120
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
121
121
  <script src="/builders/palette.js" defer></script>
122
122
  <!-- task 2166: the shared component kit (window.OTBKit) — pager/ledger/rail.
@@ -138,10 +138,13 @@
138
138
  <script src="/builders/city-draw.js" defer></script>
139
139
  <!-- task 1005196: the hour of the city's sky (sets the --sky-now-* tokens the city spends) -->
140
140
  <script src="/builders/sky-time.js?v=2026-11-11-helm4" defer></script>
141
- <script src="/builders/home-city-draw.js?v=2026-11-11-helm4d" defer></script>
141
+ <script src="/builders/home-city-draw.js?v=2026-11-12-runner" defer></script>
142
142
  <script src="/builders/home-gallery.js" defer></script>
143
143
  <script src="/builders/home-idea-box.js" defer></script>
144
- <script src="/builders/home-city.js?v=2026-11-10-visor" defer></script>
144
+ <!-- task 1005243: your runner on Home's towers (game mode; the visor carries its card and button) -->
145
+ <script src="/builders/runner-button.js" defer></script>
146
+ <script src="/builders/home-runner.js" defer></script>
147
+ <script src="/builders/home-city.js?v=2026-11-12-runner" defer></script>
145
148
  <!-- task 1003455: the shared blocker-row renderer (window.OTBBlockersLib), read by
146
149
  both goal-inbox.js's attention-inbox slice below and the /blockers page.
147
150
  Loaded BEFORE goal-inbox.js, which calls it while rendering. -->
@@ -169,5 +169,32 @@ html[data-game="on"] .visor:not([hidden]) {
169
169
  .visor__row--hint:hover { color: var(--ink-soft); }
170
170
  .visor__sheet .visor__key { color: var(--ink-soft); background: none; }
171
171
 
172
+ /* the card for what the scene picked (task 1005243): Home's tower and your runner on it. Centred
173
+ over the accent button it hands its one action to, the night panel between two strong
174
+ hairlines like the sheet, never wider than a short paragraph. The words are the scene's facts;
175
+ the status dot is the hall's status vocabulary (--ok / --warn / --info), never a colour of its own. */
176
+ .visor__focus {
177
+ position: absolute; left: 50%; bottom: calc(var(--visor-inset-bottom) + 72px); transform: translateX(-50%);
178
+ pointer-events: auto; box-sizing: border-box; width: min(20rem, calc(100vw - 2 * var(--ds-space-gutter-narrow)));
179
+ padding: var(--ds-space-xs) var(--ds-space-sm) var(--ds-space-sm); background: var(--bg-card);
180
+ border: 0 solid var(--rule); border-left-width: var(--line-strong); border-right-width: var(--line-strong);
181
+ }
182
+ .visor__focus-head { display: flex; align-items: center; justify-content: space-between; gap: var(--ds-space-xs); }
183
+ .visor__focus-title {
184
+ margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
185
+ font-family: var(--name-face); font-weight: var(--name-weight); text-transform: var(--name-case);
186
+ font-size: var(--ds-text-xl); letter-spacing: var(--name-space); color: var(--ink);
187
+ }
188
+ .visor__focus-status { display: flex; align-items: center; gap: var(--ds-space-xs); margin: 2px 0 0; font-size: var(--ds-text-md); color: var(--ink); }
189
+ .visor__focus-status i { width: 10px; height: 10px; flex: none; background: var(--rule); }
190
+ .visor__focus-status--ok i { background: var(--ok); }
191
+ .visor__focus-status--warn i { background: var(--warn); }
192
+ .visor__focus-status--info i { background: var(--info); }
193
+ .visor__focus-status--danger i { background: var(--danger); }
194
+ .visor__focus-lines { list-style: none; margin: var(--ds-space-xs) 0 0; padding: 0; color: var(--ink-soft); font-size: var(--ds-text-sm); line-height: 1.45; }
195
+ .visor__focus-lines li + li { margin-top: 2px; }
196
+ .visor__focus .visor__key { color: var(--ink-soft); background: none; }
197
+ .visor__main--lead[aria-busy="true"] { cursor: progress; background: var(--accent-lift); }
198
+
172
199
  /* a phone (640px and narrower): the rules live in style.css’s shared phone block, so the
173
200
  visor adds no breakpoint of its own (the design-count ratchet). */
@@ -151,6 +151,64 @@
151
151
  render();
152
152
  }
153
153
 
154
+ // ---------- what the scene has picked (task 1005243) ----------
155
+ // A scene that picks something (Home's tower) hands the visor a small card about it:
156
+ // a title, one status, a few lines, and at most one action. The action becomes the
157
+ // accent button while the card is up (ADR 0403 §4: a scene's lead action reaches the
158
+ // accent button); the character's own main action is back when the card closes.
159
+ // The scene paints the pick; the visor paints the words and the button.
160
+ var focus = null;
161
+ function setFocus(f) {
162
+ var was = focus;
163
+ if (!f || typeof f !== 'object' || !f.title) focus = null;
164
+ else {
165
+ var lead = f.lead && f.lead.label && typeof f.lead.run === 'function' ? { label: String(f.lead.label), run: f.lead.run, busy: !!f.lead.busy } : null;
166
+ focus = {
167
+ key: String(f.key || f.title), title: String(f.title),
168
+ status: f.status && f.status.text ? { text: String(f.status.text), tone: String(f.status.tone || 'neutral') } : null,
169
+ lines: (Array.isArray(f.lines) ? f.lines : []).filter(Boolean).map(String).slice(0, 4),
170
+ lead: lead, onClose: typeof f.onClose === 'function' ? f.onClose : null,
171
+ };
172
+ }
173
+ if (focus && !was) document.addEventListener('keydown', onFocusKey, true);
174
+ if (!focus && was) document.removeEventListener('keydown', onFocusKey, true);
175
+ render();
176
+ }
177
+ // Closed by the person (Escape or the close mark): the scene is told, so it unpicks.
178
+ function dropFocus() {
179
+ var f = focus;
180
+ if (!f) return;
181
+ setFocus(null);
182
+ if (f.onClose) f.onClose();
183
+ }
184
+ // Escape closes the card before it reaches the options menu, but never while the sheet
185
+ // is open: the sheet's own capture listener takes that press.
186
+ function onFocusKey(e) {
187
+ if (!focus || sheet || e.key !== 'Escape' || !shown()) return;
188
+ e.preventDefault();
189
+ e.stopPropagation();
190
+ dropFocus();
191
+ }
192
+ var TONES = { ok: 1, warn: 1, info: 1, neutral: 1, danger: 1 };
193
+ function focusHtml() {
194
+ if (!focus) return '';
195
+ var st = focus.status;
196
+ return '<div class="visor__focus" role="group" aria-label="' + esc(focus.title) + '" aria-live="polite" data-visor-focus>' +
197
+ '<div class="visor__focus-head"><p class="visor__focus-title">' + esc(focus.title) + '</p>' +
198
+ '<button class="visor__close" type="button" data-visor-unfocus aria-label="Close">' + keyHtml('Esc') + '</button></div>' +
199
+ (st ? '<p class="visor__focus-status visor__focus-status--' + (TONES[st.tone] ? st.tone : 'neutral') + '"><i aria-hidden="true"></i>' + esc(st.text) + '</p>' : '') +
200
+ (focus.lines.length ? '<ul class="visor__focus-lines">' + focus.lines.map(function (l) { return '<li>' + esc(l) + '</li>'; }).join('') + '</ul>' : '') +
201
+ '</div>';
202
+ }
203
+ function mainHtml(main) {
204
+ if (focus && focus.lead) {
205
+ return '<button class="visor__main visor__main--lead" type="button" data-visor-lead' + (focus.lead.busy ? ' aria-busy="true"' : '') + '>' +
206
+ '<span>' + esc(focus.lead.label) + '</span></button>';
207
+ }
208
+ return '<a class="visor__main" data-visor-act="' + esc(main.id) + '" ' + actionAttrs(main) + '>' +
209
+ '<span>' + esc(main.label) + '</span>' + keyHtml(main.key) + '</a>';
210
+ }
211
+
154
212
  // ---------- drawing ----------
155
213
  var root = null, sheet = null, returnFocus = null;
156
214
  var NS = 'http://www.w3.org/2000/svg';
@@ -197,14 +255,14 @@
197
255
  return frameSvg() +
198
256
  (scene.place ? '<p class="visor__place">' + esc(scene.place) + '</p>' : '') +
199
257
  progressHtml() +
258
+ focusHtml() +
200
259
  '<div class="visor__foot">' +
201
260
  '<div class="visor__me">' +
202
261
  (sprite ? '<img class="visor__sprite" src="' + esc(sprite) + '" alt="" width="32" height="32" decoding="async">' : '') +
203
262
  '<span class="visor__who"><span class="visor__name">' + esc(NAMES[id]) + '</span>' +
204
263
  '<span class="visor__credits" data-visor-credits>' + creditsHtml(n) + '</span></span>' +
205
264
  '</div>' +
206
- '<a class="visor__main" data-visor-act="' + esc(main.id) + '" ' + actionAttrs(main) + '>' +
207
- '<span>' + esc(main.label) + '</span>' + keyHtml(main.key) + '</a>' +
265
+ mainHtml(main) +
208
266
  '<div class="visor__hints">' + scene.actions.map(function (a, i) { return a.lead ? placeHintHtml(a, i) : ''; }).join('') + acts.slice(1).map(hintHtml).join('') +
209
267
  '<button class="visor__hint visor__hint--sheet" type="button" data-visor-sheet aria-expanded="false" aria-controls="visor-sheet">' +
210
268
  '<span class="visor__dots" aria-hidden="true"></span>' + keyHtml(SHEET_KEY) + '<span>Actions</span></button>' +
@@ -328,6 +386,8 @@
328
386
  var t = e.target && e.target.closest ? e.target : null;
329
387
  if (!t) return;
330
388
  if (t.closest('[data-visor-close]')) { closeSheet(true); return; }
389
+ if (t.closest('[data-visor-unfocus]')) { dropFocus(); return; }
390
+ if (t.closest('[data-visor-lead]')) { if (focus && focus.lead && !focus.lead.busy) { closeSheet(false); focus.lead.run(); } return; }
331
391
  if (t.closest('[data-visor-sheet]')) { openSheet(); return; }
332
392
  var sc = t.closest('[data-visor-scene]');
333
393
  if (sc && scene) { var a = scene.actions[Number(sc.getAttribute('data-visor-scene'))]; closeSheet(false); if (a) a.run(); return; }
@@ -389,6 +449,9 @@
389
449
 
390
450
  window.OTBVisor = {
391
451
  scene: registerScene,
452
+ // a scene's pick: OTBVisor.focus({ title, status, lines, lead: { label, run }, onClose }) or focus(null)
453
+ focus: setFocus,
454
+ focused: function () { return focus ? focus.key : null; },
392
455
  render: render,
393
456
  open: openSheet,
394
457
  close: function () { closeSheet(true); },