@staix/agent-hub 0.13.0 → 0.14.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +6 -4
  3. package/docs/agent-notes/adapters.md +1 -1
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/daemon.md +8 -1
  6. package/docs/agent-notes/tasks.md +7 -4
  7. package/docs/agent-notes/tests.md +1 -1
  8. package/docs/dogfood.md +10 -7
  9. package/docs/operations.md +45 -13
  10. package/docs/quickstart.md +6 -2
  11. package/docs/sdlc.md +23 -2
  12. package/docs/security.md +9 -5
  13. package/docs/specs/2026-09-19-agent-hub-design.md +38 -0
  14. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  15. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  16. package/docs/verified.json +20 -15
  17. package/package.json +1 -1
  18. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  19. package/plugins/agent-hub/server.js +91 -20
  20. package/src/adapters/codex-review-isolation.ts +18 -7
  21. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  22. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  23. package/src/adapters/reviewer-print.ts +34 -7
  24. package/src/cli/help.ts +2 -1
  25. package/src/cli/main.ts +5 -0
  26. package/src/cli/output.ts +47 -2
  27. package/src/cli/status-lines.ts +1 -1
  28. package/src/hub/board.ts +22 -1
  29. package/src/hub/daemon.ts +84 -14
  30. package/src/hub/hub-tools.ts +2 -1
  31. package/src/hub/instances.ts +14 -4
  32. package/src/hub/manager.ts +2 -2
  33. package/src/hub/sdlc-metrics.ts +260 -0
  34. package/src/hub/sdlc.ts +5 -3
  35. package/src/hub/spec-read.ts +31 -0
  36. package/src/hub/task-code-group.ts +52 -0
  37. package/src/hub/tasks.ts +172 -22
  38. package/src/hub/ui.ts +3 -0
  39. package/src/hub/usage-rollup.ts +58 -0
  40. package/src/ui/index.html +273 -2
package/src/ui/index.html CHANGED
@@ -325,6 +325,13 @@ main>.stack{margin-top:var(--space-6)}
325
325
  .plan-block form,.plan-block .content{padding:0}
326
326
  /* A group is a plain heading and its lines: the global section{} box rule must not wrap each one in a card. */
327
327
  .detail-group{margin:var(--space-4) 0;border:0;background:none;border-radius:0;overflow:visible}
328
+ .code-symbols{list-style:none;margin:0;padding:0;display:grid;gap:var(--space-1)}
329
+ .code-symbol{font-family:var(--font-mono);font-size:var(--text-sm);overflow-wrap:anywhere;text-align:left;min-height:44px;min-width:44px}
330
+ button.code-symbol{background:var(--panel-soft);color:var(--ink);border:1px solid var(--line);border-radius:var(--radius-control);padding:var(--space-1) var(--space-2)}
331
+ button.code-symbol:hover{background:var(--hover)}
332
+ button.code-symbol.unplanned{color:var(--amber);background:var(--amber-soft);border-color:var(--amber)}
333
+ button.code-symbol.unplanned:hover{background:var(--hover)}
334
+ span.code-symbol.untouched{color:var(--muted)}
328
335
  .detail-heading{font-size:var(--text-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin:0 0 var(--space-2);border-bottom:1px solid var(--line);padding-bottom:var(--space-1)}
329
336
  #task-detail-dialog::backdrop{background:var(--scrim)}
330
337
  .dialog-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)}
@@ -357,6 +364,34 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
357
364
  .skeleton-bar{display:block;height:var(--space-4);border-radius:var(--radius-control);background:var(--line)}
358
365
  .skeleton-tall{height:var(--space-8)}
359
366
  @media(max-width:600px){.skeleton-board{flex-direction:column}}
367
+ /* Usage view */
368
+ .usage-summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--space-3);margin-bottom:var(--space-4)}
369
+ .usage-stat-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-surface);padding:var(--space-4)}
370
+ .usage-stat-label{display:block;font-size:var(--text-xs);color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-1)}
371
+ .usage-stat-value{display:block;font-size:var(--text-xl);font-weight:700;font-family:var(--font-mono);color:var(--ink)}
372
+ .usage-stat-sub{display:block;font-size:var(--text-xs);color:var(--muted);margin-top:var(--space-1)}
373
+ .usage-block{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-surface);padding:var(--space-4);margin-bottom:var(--space-4);min-width:0}
374
+ .usage-block h3{font-size:var(--text-base);margin:0 0 var(--space-3)}
375
+ .usage-chart-wrap{overflow-x:auto;padding-bottom:var(--space-2)}
376
+ .usage-daily-bars{display:flex;align-items:flex-end;gap:var(--space-3);min-height:180px;padding:var(--space-2) 0}
377
+ .usage-day-col{display:flex;flex-direction:column;align-items:center;flex:1 1 0;min-width:48px}
378
+ .usage-stacked-bar{width:100%;max-width:40px;height:140px;background:var(--panel-soft);border:1px solid var(--line);border-radius:var(--radius-control);display:flex;flex-direction:column-reverse;overflow:hidden}
379
+ .usage-bar-segment{width:100%}
380
+ .usage-day-label{font-size:var(--text-xs);color:var(--muted);font-family:var(--font-mono);margin-top:var(--space-2);white-space:nowrap}
381
+ .usage-day-total{font-size:var(--text-xs);color:var(--ink);font-family:var(--font-mono);margin-bottom:var(--space-1);white-space:nowrap}
382
+ .usage-legend{display:flex;flex-wrap:wrap;gap:var(--space-3);margin-top:var(--space-3);padding-top:var(--space-3);border-top:1px solid var(--line)}
383
+ .usage-legend-item{display:inline-flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);color:var(--ink)}
384
+ .usage-table-wrap{overflow-x:auto;max-width:100%}
385
+ .usage-table{width:100%;border-collapse:collapse;font-size:var(--text-sm);text-align:left}
386
+ .usage-table th,.usage-table td{padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--line);white-space:nowrap}
387
+ .usage-table th{font-size:var(--text-xs);color:var(--muted);text-transform:uppercase;letter-spacing:.05em;background:var(--panel-soft)}
388
+ .usage-table tr:hover td{background:var(--hover)}
389
+ .usage-table .num{font-family:var(--font-mono);text-align:right}
390
+ .usage-tag{display:inline-block;padding:1px var(--space-2);border-radius:var(--radius-control);font-size:var(--text-xs);font-family:var(--font-mono)}
391
+ .usage-tag.pii{background:var(--amber-soft);color:var(--amber);border:1px solid var(--amber)}
392
+ .meta.usage-behind{color:var(--amber);background:var(--amber-soft);border:1px solid var(--amber);border-radius:var(--radius-control);padding:var(--space-2) var(--space-3)}
393
+ .usage-paused-alert{border:1px solid var(--amber);background:var(--amber-soft);color:var(--amber);padding:var(--space-3);border-radius:var(--radius-control);margin-bottom:var(--space-3);font-size:var(--text-sm)}
394
+ @media(max-width:600px){.usage-summary-grid{grid-template-columns:1fr}}
360
395
  </style>
361
396
  </head>
362
397
  <body>
@@ -404,7 +439,45 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
404
439
  <section id="projects-panel" aria-labelledby="projects-title" data-view="agents" hidden><div class="section-head"><h2 id="projects-title">Projects</h2><span id="project-count" class="meta"></span></div><div id="projects"></div></section>
405
440
  <section aria-labelledby="peers-title" data-view="agents" hidden><div class="section-head"><h2 id="peers-title">Peers &amp; queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"><div class="skeleton" aria-hidden="true"><span class="skeleton-bar skeleton-tall"></span><span class="skeleton-bar skeleton-tall"></span><span class="skeleton-bar skeleton-tall"></span></div><p class="empty">Loading peers...</p></div></section>
406
441
  <section aria-labelledby="permissions-title" data-view="agents" hidden><div class="section-head"><h2 id="permissions-title">Pending approvals</h2><span id="permission-count" class="meta"></span></div><div id="permissions"></div></section>
407
- <section aria-labelledby="budget-title" data-view="agents" hidden><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div></section>
442
+ <section aria-labelledby="budget-title" data-view="agents" hidden><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div>
443
+ <div id="usage-content" class="content">
444
+ <div class="usage-summary-grid">
445
+ <div class="usage-stat-card"><span class="usage-stat-label">Lifetime Tokens</span><span id="usage-lifetime-total" class="usage-stat-value">-</span><span id="usage-lifetime-total-sub" class="usage-stat-sub"></span></div>
446
+ <div class="usage-stat-card"><span class="usage-stat-label">Without Cache</span><span id="usage-lifetime-nocache" class="usage-stat-value">-</span><span id="usage-lifetime-nocache-sub" class="usage-stat-sub"></span></div>
447
+ <div class="usage-stat-card"><span class="usage-stat-label">Cache Reads</span><span id="usage-lifetime-cache" class="usage-stat-value">-</span><span id="usage-lifetime-cache-sub" class="usage-stat-sub"></span></div>
448
+ </div>
449
+ <p id="usage-coverage-note" class="meta">Coverage: Jcode and Kimi (prior to #285) report hub-counted increments; unrecorded sources are noted as unknown.</p>
450
+
451
+ <div class="usage-block">
452
+ <div class="section-subhead"><h3>Daily tokens per peer</h3><span id="usage-daily-meta" class="meta"></span></div>
453
+ <div class="usage-chart-wrap"><div id="usage-daily-chart" class="usage-daily-bars"></div></div>
454
+ <div id="usage-daily-legend" class="usage-legend"></div>
455
+ </div>
456
+
457
+ <div class="usage-block">
458
+ <div class="section-subhead"><h3>Limits &amp; quota timeline</h3></div>
459
+ <div id="usage-paused-alert" class="usage-paused-alert" hidden></div>
460
+ <div id="usage-budget-windows" class="budget-grid"></div>
461
+ <p id="usage-limits-note" class="meta" hidden></p>
462
+ <div class="usage-table-wrap">
463
+ <table class="usage-table">
464
+ <thead><tr><th>Time</th><th>Event</th><th>Peer</th><th>Window</th><th>Detail</th></tr></thead>
465
+ <tbody id="usage-limits-tbody"></tbody>
466
+ </table>
467
+ </div>
468
+ </div>
469
+
470
+ <div class="usage-block">
471
+ <div class="section-subhead"><h3>Task tokens</h3><span id="usage-task-count" class="meta"></span></div>
472
+ <div class="usage-table-wrap">
473
+ <table id="usage-task-table" class="usage-table">
474
+ <thead><tr><th>Task</th><th>State</th><th>Owner</th><th>PR</th><th class="num">Input</th><th class="num">Output</th><th class="num">Cache read</th><th class="num">Cache write</th><th class="num">Total</th></tr></thead>
475
+ <tbody id="usage-task-tbody"></tbody>
476
+ </table>
477
+ </div>
478
+ </div>
479
+ </div>
480
+ </section>
408
481
  <section aria-labelledby="bench-title" data-view="agents" hidden><div class="section-head"><h2 id="bench-title">Benchmarks</h2><span id="bench-count" class="meta"></span></div><div id="bench"></div></section>
409
482
  </div>
410
483
  </main>
@@ -1028,6 +1101,37 @@ function dropTarget(node, accepted, payload) {
1028
1101
  void action({...payload, id:card.id});
1029
1102
  });
1030
1103
  }
1104
+ // #684 2c: the sheet's Code group. The hub classified the symbols (`task.codeGroup`, absent for a private task, so nothing renders for one);
1105
+ // the page only draws them. A symbol is a button that opens the Memory tab with its canonical node (`symbol:<name>@<path>`) as the graph start.
1106
+ function codeGroupSection(task) {
1107
+ const group = task.codeGroup;
1108
+ if (!group) return null;
1109
+ const section = el('section', undefined, 'detail-group code-group'); section.append(el('h4', 'Code', 'detail-heading'));
1110
+ if (group.state !== 'known') { section.append(el('p', 'unknown (no code facts)', 'meta')); return section; }
1111
+ const list = el('ul', undefined, 'code-symbols');
1112
+ for (const symbol of group.symbols || []) {
1113
+ const item = el('li'), open = el('button', symbol.name, 'code-symbol ' + symbol.mark);
1114
+ open.type = 'button'; open.dataset.symbolNode = symbol.id; open.title = symbol.path + ' (' + symbol.mark + '); open in Memory';
1115
+ open.setAttribute('aria-label', symbol.name + ' in ' + symbol.path + ', ' + symbol.mark + '; open in Memory');
1116
+ open.addEventListener('click', () => openMemoryAt(symbol.id));
1117
+ item.append(open, el('span', ' ' + symbol.mark + ' / ' + symbol.path, 'meta'));
1118
+ list.append(item);
1119
+ }
1120
+ for (const name of group.untouched || []) { const item = el('li'); item.append(el('span', name, 'code-symbol untouched'), el('span', ' untouched (planned, nothing changed under it)', 'meta')); list.append(item); }
1121
+ if (list.children.length) section.append(list); else section.append(el('p', 'none observed', 'meta'));
1122
+ const counts = group.counts || { matching: 0, unplanned: 0, untouched: 0 };
1123
+ const note = counts.matching + ' matching, ' + counts.unplanned + ' unplanned, ' + counts.untouched + ' untouched' + (group.head ? ' at ' + group.head : '');
1124
+ section.append(el('p', note + (group.more ? '; ' + group.more + ' more not shown' : '')
1125
+ + (group.planWithheld ? '; ' + group.planWithheld + ' plan name' + (group.planWithheld === 1 ? ' is' : 's are') + ' withheld, so a symbol marked unplanned may be in the plan' : '')
1126
+ + (group.complete ? '' : '; incomplete: an untouched symbol may have changed, and a symbol marked unplanned may be in the plan')
1127
+ + '; advisory: deleted symbols are not reported', 'meta'));
1128
+ return section;
1129
+ }
1130
+ function openMemoryAt(node) {
1131
+ if (typeof node !== 'string' || !node.startsWith('symbol:')) return;
1132
+ memoryQuery.node = node; memoryQuery.q = ''; $('memory-q').value = '';
1133
+ closeTaskDetail(); switchView('memory'); rerenderMemory();
1134
+ }
1031
1135
  function taskDetail(task, card, stage, ids, overdue) {
1032
1136
  const row = el('article', undefined, 'task-detail'), head = el('div', undefined, 'row-head');
1033
1137
  row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
@@ -1058,6 +1162,7 @@ function taskDetail(task, card, stage, ids, overdue) {
1058
1162
  if(task.external?.provider==='github'){const source=el('a','GitHub #'+task.external.issue);source.href=task.external.url;source.rel='noreferrer';source.target='_blank';links.append(source);}
1059
1163
  if(task.pinned)links.append(el('p','Pinned','meta'));
1060
1164
  if (links.children.length > 1) row.append(links);
1165
+ const code = codeGroupSection(task); if (code) row.append(code);
1061
1166
  const controls = el('div', undefined, 'actions');
1062
1167
  if (card.pull && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Pull into To Do', {action:'backlog_pull',id:task.id}));
1063
1168
  if (card.pull && boardViewState?.snapshot.planningCapabilities?.wipPull===true) controls.append(boardButton('Pull within WIP', {action:'planner_pull',id:task.id,revision:task.planningRevision ?? 0,reason:'person requested a WIP-constrained pull'}));
@@ -1228,6 +1333,171 @@ function renderNeedsAttention(snapshot) {
1228
1333
  const approvals=$('agents-approval-count');approvals.textContent=approvalCount?'('+approvalCount+')':'';approvals.hidden=!approvalCount;
1229
1334
  const title=value?.act?'('+value.act+') agent-hub':attentionBaseTitle;if(document.title!==title)document.title=title;
1230
1335
  }
1336
+ function formatCounter(c, exact) {
1337
+ if (!c || c.value === null || c.value === undefined) return 'unknown';
1338
+ const val = c.value;
1339
+ let str;
1340
+ if (exact || Math.abs(val) < 1000) str = String(val);
1341
+ else if (val >= 1e7) str = Math.round(val / 1e6) + 'M';
1342
+ else if (val >= 1e6) str = (val / 1e6).toFixed(1) + 'M';
1343
+ else if (val >= 1e4) str = Math.round(val / 1e3) + 'k';
1344
+ else str = (val / 1e3).toFixed(1) + 'k';
1345
+ const tag = c.source === 'count' ? 'hub' : c.source === 'partial' ? 'at least' : c.source;
1346
+ return str + (tag && tag !== 'native' ? ' (' + tag + ')' : '');
1347
+ }
1348
+ const USAGE_LIMIT_ROWS = 20;
1349
+ const USAGE_COVERAGE_NOTE = 'Coverage: Jcode and Kimi (prior to #285) report hub-counted increments; unrecorded sources are noted as unknown.';
1350
+ function renderUsage(usage, tasks, budget) {
1351
+ if (!usage) return;
1352
+ // A failed read is not a hidden view: replace the old figures with an explicit state so stale totals are never shown as current.
1353
+ if (usage.unavailable === true) {
1354
+ for (const id of ['usage-lifetime-total', 'usage-lifetime-nocache', 'usage-lifetime-cache']) $(id).textContent = 'unavailable';
1355
+ for (const id of ['usage-lifetime-total-sub']) $(id).textContent = '';
1356
+ $('usage-coverage-note').textContent = USAGE_COVERAGE_NOTE + ' The usage roll-up could not be read just now; the figures were cleared and will return on the next poll.';
1357
+ $('usage-coverage-note').classList.toggle('usage-behind', true);
1358
+ $('usage-daily-chart').replaceChildren(el('p', 'Usage unavailable.', 'empty'));
1359
+ $('usage-daily-legend').replaceChildren();
1360
+ $('usage-paused-alert').hidden = true;
1361
+ $('usage-limits-tbody').replaceChildren();
1362
+ $('usage-task-tbody').replaceChildren();
1363
+ $('usage-task-count').textContent = 'unavailable';
1364
+ return;
1365
+ }
1366
+ // #683 U4: a roll-up that has not folded the whole log gives lower bounds; say so rather than show the folded part as the whole.
1367
+ const coverage = $('usage-coverage-note');
1368
+ coverage.textContent = USAGE_COVERAGE_NOTE + (usage.behind === true ? ' Still folding the event log: figures are a lower bound until it catches up.' : '');
1369
+ coverage.classList.toggle('usage-behind', usage.behind === true);
1370
+ const total = usage.total;
1371
+ if (total) {
1372
+ const overall = total.overall;
1373
+ $('usage-lifetime-total').textContent = formatCounter(overall?.tokens);
1374
+ const unknownPeers = total.unknownPeers?.tokens || [];
1375
+ $('usage-lifetime-total-sub').textContent = unknownPeers.length ? 'Unknown: ' + unknownPeers.join(', ') : (total.since ? 'Since ' + total.since.slice(0, 10) : '');
1376
+ $('usage-lifetime-nocache').textContent = formatCounter(overall?.withoutCache);
1377
+ $('usage-lifetime-cache').textContent = formatCounter(overall?.cacheRead);
1378
+ }
1379
+ const daily = usage.daily;
1380
+ const chart = $('usage-daily-chart'), legend = $('usage-daily-legend');
1381
+ if (daily && daily.days && daily.days.length) {
1382
+ chart.replaceChildren();
1383
+ let maxDay = 1;
1384
+ for (const d of daily.days) {
1385
+ if (d.total?.value && d.total.value > maxDay) maxDay = d.total.value;
1386
+ }
1387
+ for (const d of daily.days) {
1388
+ const col = el('div', undefined, 'usage-day-col');
1389
+ col.append(el('span', d.empty === true ? 'no record' : formatCounter(d.total), 'usage-day-total'));
1390
+ const bar = el('div', undefined, 'usage-stacked-bar');
1391
+ bar.title = d.day + ': ' + (d.empty === true ? 'no record' : formatCounter(d.total));
1392
+ for (const p of daily.peers || []) {
1393
+ const c = d.peers[p];
1394
+ if (c && c.value && c.value > 0) {
1395
+ const seg = el('div', undefined, 'usage-bar-segment peer-' + p);
1396
+ const pct = Math.max(2, Math.round((c.value / maxDay) * 100));
1397
+ seg.style.height = pct + '%';
1398
+ seg.style.backgroundColor = 'var(--peer-color)';
1399
+ seg.title = p + ': ' + formatCounter(c);
1400
+ bar.append(seg);
1401
+ }
1402
+ }
1403
+ col.append(bar, el('span', d.day.slice(5), 'usage-day-label'));
1404
+ chart.append(col);
1405
+ }
1406
+ legend.replaceChildren();
1407
+ for (const p of daily.peers || []) {
1408
+ const item = el('span', undefined, 'usage-legend-item peer-' + p);
1409
+ const dot = el('span', undefined, 'peer-color-dot peer-' + p);
1410
+ dot.style.backgroundColor = 'var(--peer-color)';
1411
+ item.append(dot, el('span', p));
1412
+ legend.append(item);
1413
+ }
1414
+ } else {
1415
+ chart.replaceChildren(el('p', 'No daily token records yet.', 'empty'));
1416
+ legend.replaceChildren();
1417
+ }
1418
+
1419
+ const limits = usage.limits;
1420
+ const pausedAlert = $('usage-paused-alert');
1421
+ if (limits?.open?.length) {
1422
+ pausedAlert.hidden = false;
1423
+ pausedAlert.textContent = 'Active budget pauses: ' + limits.open.map(p => p.peer + ' (' + p.window + (p.resetsAt ? ', resets ' + rel(p.resetsAt) : '') + ')').join('; ');
1424
+ } else {
1425
+ pausedAlert.hidden = true;
1426
+ }
1427
+
1428
+ const limitsTbody = $('usage-limits-tbody');
1429
+ limitsTbody.replaceChildren();
1430
+ const history = limits?.history || [];
1431
+ // The timeline shows the newest rows only: say how many older events it leaves out, from the hub's bound and from this view's.
1432
+ const hidden = Math.max(0, history.length - USAGE_LIMIT_ROWS) + (Number.isFinite(limits?.omitted) ? limits.omitted : 0);
1433
+ const limitsNote = $('usage-limits-note');
1434
+ limitsNote.hidden = hidden === 0;
1435
+ limitsNote.textContent = hidden === 0 ? '' : 'Showing the newest ' + Math.min(history.length, USAGE_LIMIT_ROWS) + ' limit events; ' + hidden + ' older ' + (hidden === 1 ? 'event is' : 'events are') + ' not shown (ahub usage limits lists them).';
1436
+ if (history.length) {
1437
+ for (const h of history.slice(-USAGE_LIMIT_ROWS).reverse()) {
1438
+ const tr = el('tr');
1439
+ const timeCell = el('td', time(Date.parse(h.at)));
1440
+ timeCell.title = h.at;
1441
+ const typeCell = el('td', h.type.replace(/^budget_/, '').replace(/^quota_/, ''));
1442
+ const peerCell = el('td', h.peer);
1443
+ const winCell = el('td', h.window);
1444
+ let detailText = '';
1445
+ if (h.detail) {
1446
+ if (h.detail.resetsAt) detailText += 'resets ' + rel(Number(h.detail.resetsAt)) + ' ';
1447
+ if (h.detail.used !== undefined) detailText += Math.round(Number(h.detail.used) * 100) + '% ';
1448
+ if (h.detail.cause) detailText += '(' + h.detail.cause + ')';
1449
+ }
1450
+ const detailCell = el('td', detailText.trim() || '-');
1451
+ tr.append(timeCell, typeCell, peerCell, winCell, detailCell);
1452
+ limitsTbody.append(tr);
1453
+ }
1454
+ } else {
1455
+ const tr = el('tr');
1456
+ const td = el('td', 'No limit events recorded yet.', 'empty');
1457
+ td.colSpan = 5;
1458
+ tr.append(td);
1459
+ limitsTbody.append(tr);
1460
+ }
1461
+
1462
+ const taskTbody = $('usage-task-tbody');
1463
+ taskTbody.replaceChildren();
1464
+ const usageTasks = usage.tasks || [];
1465
+ const taskUsageMap = new Map(usageTasks.map(t => [t.id, t]));
1466
+ const boardTasks = tasks || [];
1467
+ const taskIds = new Set([...boardTasks.map(t => t.id), ...usageTasks.map(t => t.id)]);
1468
+ const sortedIds = [...taskIds].sort((a, b) => b - a);
1469
+ let taskCount = 0;
1470
+ for (const id of sortedIds) {
1471
+ if (id === 0) continue;
1472
+ const bt = boardTasks.find(t => t.id === id);
1473
+ const ut = taskUsageMap.get(id);
1474
+ // A PII task's public title is exactly "[pii]" (Tasks.publicView), so the board row alone marks it even when it has no usage rows.
1475
+ const pii = ut?.pii === true || bt?.title === '[pii]';
1476
+ const tr = el('tr');
1477
+ const taskCell = el('td');
1478
+ if (pii) {
1479
+ taskCell.append(el('span', '#' + id + ' [pii]', 'usage-tag pii'));
1480
+ } else {
1481
+ taskCell.textContent = '#' + id + (bt?.title ? ' ' + bt.title : '');
1482
+ }
1483
+ const stateCell = el('td', bt?.state ? bt.state.replaceAll('_', ' ') : '-');
1484
+ const ownerCell = el('td', bt?.owner || '-');
1485
+ const prNum = bt?.lifecycle?.pr || bt?.sdlc?.pr;
1486
+ const prCell = el('td', prNum ? '#' + prNum : (bt?.refs?.branch || '-'));
1487
+
1488
+ const inputCell = el('td', formatCounter(ut?.input), 'num');
1489
+ const outputCell = el('td', formatCounter(ut?.output), 'num');
1490
+ const crCell = el('td', formatCounter(ut?.cacheRead), 'num');
1491
+ const cwCell = el('td', formatCounter(ut?.cacheWrite), 'num');
1492
+ // `total` is a Counter object even when nothing native was reported, so choose by its value: a tokens-only task (Jcode, older Kimi) falls back to the hub's count.
1493
+ const totalCell = el('td', formatCounter(ut?.total?.value != null ? ut.total : ut?.counted), 'num');
1494
+
1495
+ tr.append(taskCell, stateCell, ownerCell, prCell, inputCell, outputCell, crCell, cwCell, totalCell);
1496
+ taskTbody.append(tr);
1497
+ taskCount++;
1498
+ }
1499
+ $('usage-task-count').textContent = taskCount + ' tasks';
1500
+ }
1231
1501
  function render(snapshot) {
1232
1502
  renderNeedsAttention(snapshot);
1233
1503
  const peers = Object.fromEntries(Object.entries(snapshot.status.peers || {}).map(([id, peer]) => [id, {...peer, ...(snapshot.status.roleInfo || {})[id]}])), ids = Object.keys(peers), starts = Array.isArray(snapshot.starts) ? snapshot.starts : [];
@@ -1309,6 +1579,7 @@ function render(snapshot) {
1309
1579
  renderBench(snapshot.bench);
1310
1580
  renderMemory(snapshot.memory);
1311
1581
  renderSettings(snapshot.settings, snapshot.roster);
1582
+ renderUsage(snapshot.usage, snapshot.tasks, snapshot.budget);
1312
1583
  const stream = $('stream');
1313
1584
  if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
1314
1585
  for (const entry of snapshot.events || []) {
@@ -1333,7 +1604,7 @@ async function poll() {
1333
1604
  const captured = context();
1334
1605
  if (managerMode && (!captured.projectId || !captured.instanceId)) { if (!stopped) timer = setTimeout(poll, 1000); return; }
1335
1606
  try {
1336
- const input = {after:cursor, memory:memoryInput()}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
1607
+ const input = {after:cursor, memory:memoryInput(), view:currentView}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
1337
1608
  const result = await post('/snapshot', input);
1338
1609
  if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
1339
1610
  snapshotValid = true; lastSnapshot = result; lastSnapshotAt = Date.now(); render(result); setConnection('Live / updated ' + new Date().toLocaleTimeString(), 'live'); setDisconnected(false);