@staix/agent-hub 0.13.0 → 0.15.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 (92) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +19 -7
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +18 -4
  5. package/docs/agent-notes/budget.md +5 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +19 -7
  8. package/docs/agent-notes/tasks.md +15 -7
  9. package/docs/agent-notes/tests.md +4 -4
  10. package/docs/dogfood.md +18 -10
  11. package/docs/events.md +21 -7
  12. package/docs/operations.md +131 -27
  13. package/docs/profiles.md +1 -0
  14. package/docs/quickstart.md +13 -3
  15. package/docs/sdlc.md +40 -2
  16. package/docs/security.md +45 -14
  17. package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
  18. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
  19. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  20. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  21. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  22. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  23. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  24. package/docs/verified.json +34 -17
  25. package/package.json +1 -1
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +1078 -327
  28. package/src/adapters/acp.ts +43 -7
  29. package/src/adapters/claude-channel.ts +26 -4
  30. package/src/adapters/claude-implementer.ts +243 -0
  31. package/src/adapters/claude-print-child.ts +90 -0
  32. package/src/adapters/claude-task-sessions.ts +80 -0
  33. package/src/adapters/codex-review-isolation.ts +18 -7
  34. package/src/adapters/compat.ts +4 -0
  35. package/src/adapters/conformance/enrollment.ts +2 -1
  36. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  37. package/src/adapters/definitions/builtin-metadata.ts +15 -3
  38. package/src/adapters/definitions/native-channel-state.ts +1 -1
  39. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  40. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  41. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  42. package/src/adapters/definitions/native-factories.ts +8 -0
  43. package/src/adapters/kimi-wire-usage.ts +303 -0
  44. package/src/adapters/registry-types.ts +7 -3
  45. package/src/adapters/registry.ts +14 -2
  46. package/src/adapters/reviewer-print.ts +64 -60
  47. package/src/cli/console-config.ts +123 -0
  48. package/src/cli/console-state.ts +28 -1
  49. package/src/cli/console.ts +25 -1
  50. package/src/cli/facts-hook.ts +9 -5
  51. package/src/cli/help.ts +6 -4
  52. package/src/cli/launch.ts +10 -4
  53. package/src/cli/main.ts +52 -9
  54. package/src/cli/output.ts +75 -2
  55. package/src/cli/reset.ts +1 -1
  56. package/src/cli/settings-edit.ts +89 -0
  57. package/src/cli/status-lines.ts +1 -1
  58. package/src/cli/terminal-recovery.ts +1 -6
  59. package/src/hub/action-enforce.ts +82 -0
  60. package/src/hub/action-policy.ts +22 -10
  61. package/src/hub/board.ts +48 -3
  62. package/src/hub/budget.ts +5 -0
  63. package/src/hub/bus.ts +5 -5
  64. package/src/hub/claude-launch-proof.ts +33 -0
  65. package/src/hub/config-apply.ts +5 -0
  66. package/src/hub/config-edit-host.ts +173 -0
  67. package/src/hub/config-edit.ts +282 -0
  68. package/src/hub/config-tier.ts +89 -0
  69. package/src/hub/control-client.ts +24 -3
  70. package/src/hub/daemon.ts +450 -71
  71. package/src/hub/deliverable-files.ts +131 -0
  72. package/src/hub/deliverables.ts +125 -0
  73. package/src/hub/delivery-journal.ts +18 -2
  74. package/src/hub/events.ts +3 -1
  75. package/src/hub/hub-tools.ts +21 -4
  76. package/src/hub/instances.ts +19 -6
  77. package/src/hub/kimi-usage.ts +144 -0
  78. package/src/hub/manager.ts +3 -3
  79. package/src/hub/peers.ts +7 -0
  80. package/src/hub/permission-payload.ts +15 -0
  81. package/src/hub/routing.ts +118 -3
  82. package/src/hub/sdlc-metrics.ts +260 -0
  83. package/src/hub/sdlc.ts +5 -3
  84. package/src/hub/settings.ts +1 -1
  85. package/src/hub/spec-read.ts +31 -0
  86. package/src/hub/task-code-group.ts +52 -0
  87. package/src/hub/tasks.ts +374 -37
  88. package/src/hub/token-window.ts +33 -0
  89. package/src/hub/ui.ts +3 -0
  90. package/src/hub/usage-rollup.ts +74 -6
  91. package/src/hub/workflow.ts +16 -0
  92. package/src/ui/index.html +388 -3
package/src/ui/index.html CHANGED
@@ -201,6 +201,15 @@ details.settings-group{border-bottom:1px solid var(--line)}
201
201
  details.settings-group>summary{background:var(--panel-soft)}
202
202
  details.settings-group .row{border-bottom:1px dashed var(--line)}
203
203
  details.settings-group .row:last-child{border-bottom:0}
204
+ /* Structure (#689): instances, lifecycle and the executable keys. Tier 3 is text only: no control, a command to run in the console. */
205
+ .structure-group .row{padding:var(--space-3) var(--space-4)}
206
+ .structure-instance,.structure-sdlc,.structure-add,.structure-backups{border-bottom:1px dashed var(--line)}
207
+ .structure-tier3{background:var(--panel-soft);color:var(--ink);border:1px solid var(--line);border-radius:var(--radius-surface);padding:var(--space-3) var(--space-4);margin:var(--space-3) var(--space-4)}
208
+ .structure-tier3 code{display:block;overflow-wrap:anywhere;color:var(--muted)}
209
+ .structure-group button,.structure-group input,.structure-group select{min-height:44px}
210
+ .structure-field{display:grid;grid-template-columns:minmax(0,12rem) minmax(0,1fr);gap:var(--space-2);align-items:center;margin:var(--space-2) 0}
211
+ .structure-diff{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--panel-soft);color:var(--ink);border:1px solid var(--line);border-radius:var(--radius-control);padding:var(--space-2) var(--space-3);margin:var(--space-2) 0}
212
+ .structure-error{color:var(--red);background:var(--red-soft);border:1px solid var(--red);border-radius:var(--radius-control);padding:var(--space-2) var(--space-3);margin:var(--space-2) 0}
204
213
  /* Peer colours: the registry's hue, one variant per theme, generated by peer-palette.ts */
205
214
  /* PEER_PALETTE_CSS */
206
215
 
@@ -233,7 +242,7 @@ details.settings-group .row:last-child{border-bottom:0}
233
242
  .board-unknown{padding:var(--space-3) var(--space-4);color:var(--amber)}
234
243
  @media(max-width:1100px){.board-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}
235
244
  @media(max-width:900px){.stack{gap:var(--space-4)}.stream{height:300px}footer{padding:var(--space-4)}}
236
- @media(max-width:600px){.split{grid-template-columns:minmax(0,1fr)}.board-columns{grid-template-columns:1fr;padding:var(--space-3)}.board-toolbar{align-items:stretch}.board-toolbar select{max-width:none;width:100%}.board-toolbar label{width:100%}}
245
+ @media(max-width:600px){.structure-field{grid-template-columns:minmax(0,1fr)}.structure-tier3{margin:var(--space-3)}.split{grid-template-columns:minmax(0,1fr)}.board-columns{grid-template-columns:1fr;padding:var(--space-3)}.board-toolbar{align-items:stretch}.board-toolbar select{max-width:none;width:100%}.board-toolbar label{width:100%}}
237
246
  /* Reduced motion (#469 R6): one rule for the whole page. Every transition and animation is off, whatever rule declares it. */
238
247
  @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important;scroll-behavior:auto!important}}
239
248
 
@@ -325,6 +334,13 @@ main>.stack{margin-top:var(--space-6)}
325
334
  .plan-block form,.plan-block .content{padding:0}
326
335
  /* A group is a plain heading and its lines: the global section{} box rule must not wrap each one in a card. */
327
336
  .detail-group{margin:var(--space-4) 0;border:0;background:none;border-radius:0;overflow:visible}
337
+ .code-symbols{list-style:none;margin:0;padding:0;display:grid;gap:var(--space-1)}
338
+ .code-symbol{font-family:var(--font-mono);font-size:var(--text-sm);overflow-wrap:anywhere;text-align:left;min-height:44px;min-width:44px}
339
+ 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)}
340
+ button.code-symbol:hover{background:var(--hover)}
341
+ button.code-symbol.unplanned{color:var(--amber);background:var(--amber-soft);border-color:var(--amber)}
342
+ button.code-symbol.unplanned:hover{background:var(--hover)}
343
+ span.code-symbol.untouched{color:var(--muted)}
328
344
  .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
345
  #task-detail-dialog::backdrop{background:var(--scrim)}
330
346
  .dialog-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)}
@@ -357,6 +373,34 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
357
373
  .skeleton-bar{display:block;height:var(--space-4);border-radius:var(--radius-control);background:var(--line)}
358
374
  .skeleton-tall{height:var(--space-8)}
359
375
  @media(max-width:600px){.skeleton-board{flex-direction:column}}
376
+ /* Usage view */
377
+ .usage-summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--space-3);margin-bottom:var(--space-4)}
378
+ .usage-stat-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-surface);padding:var(--space-4)}
379
+ .usage-stat-label{display:block;font-size:var(--text-xs);color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-1)}
380
+ .usage-stat-value{display:block;font-size:var(--text-xl);font-weight:700;font-family:var(--font-mono);color:var(--ink)}
381
+ .usage-stat-sub{display:block;font-size:var(--text-xs);color:var(--muted);margin-top:var(--space-1)}
382
+ .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}
383
+ .usage-block h3{font-size:var(--text-base);margin:0 0 var(--space-3)}
384
+ .usage-chart-wrap{overflow-x:auto;padding-bottom:var(--space-2)}
385
+ .usage-daily-bars{display:flex;align-items:flex-end;gap:var(--space-3);min-height:180px;padding:var(--space-2) 0}
386
+ .usage-day-col{display:flex;flex-direction:column;align-items:center;flex:1 1 0;min-width:48px}
387
+ .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}
388
+ .usage-bar-segment{width:100%}
389
+ .usage-day-label{font-size:var(--text-xs);color:var(--muted);font-family:var(--font-mono);margin-top:var(--space-2);white-space:nowrap}
390
+ .usage-day-total{font-size:var(--text-xs);color:var(--ink);font-family:var(--font-mono);margin-bottom:var(--space-1);white-space:nowrap}
391
+ .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)}
392
+ .usage-legend-item{display:inline-flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);color:var(--ink)}
393
+ .usage-table-wrap{overflow-x:auto;max-width:100%}
394
+ .usage-table{width:100%;border-collapse:collapse;font-size:var(--text-sm);text-align:left}
395
+ .usage-table th,.usage-table td{padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--line);white-space:nowrap}
396
+ .usage-table th{font-size:var(--text-xs);color:var(--muted);text-transform:uppercase;letter-spacing:.05em;background:var(--panel-soft)}
397
+ .usage-table tr:hover td{background:var(--hover)}
398
+ .usage-table .num{font-family:var(--font-mono);text-align:right}
399
+ .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)}
400
+ .usage-tag.pii{background:var(--amber-soft);color:var(--amber);border:1px solid var(--amber)}
401
+ .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)}
402
+ .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)}
403
+ @media(max-width:600px){.usage-summary-grid{grid-template-columns:1fr}}
360
404
  </style>
361
405
  </head>
362
406
  <body>
@@ -404,7 +448,45 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
404
448
  <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
449
  <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
450
  <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>
451
+ <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>
452
+ <div id="usage-content" class="content">
453
+ <div class="usage-summary-grid">
454
+ <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>
455
+ <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>
456
+ <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>
457
+ </div>
458
+ <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>
459
+
460
+ <div class="usage-block">
461
+ <div class="section-subhead"><h3>Daily tokens per peer</h3><span id="usage-daily-meta" class="meta"></span></div>
462
+ <div class="usage-chart-wrap"><div id="usage-daily-chart" class="usage-daily-bars"></div></div>
463
+ <div id="usage-daily-legend" class="usage-legend"></div>
464
+ </div>
465
+
466
+ <div class="usage-block">
467
+ <div class="section-subhead"><h3>Limits &amp; quota timeline</h3></div>
468
+ <div id="usage-paused-alert" class="usage-paused-alert" hidden></div>
469
+ <div id="usage-budget-windows" class="budget-grid"></div>
470
+ <p id="usage-limits-note" class="meta" hidden></p>
471
+ <div class="usage-table-wrap">
472
+ <table class="usage-table">
473
+ <thead><tr><th>Time</th><th>Event</th><th>Peer</th><th>Window</th><th>Detail</th></tr></thead>
474
+ <tbody id="usage-limits-tbody"></tbody>
475
+ </table>
476
+ </div>
477
+ </div>
478
+
479
+ <div class="usage-block">
480
+ <div class="section-subhead"><h3>Task tokens</h3><span id="usage-task-count" class="meta"></span></div>
481
+ <div class="usage-table-wrap">
482
+ <table id="usage-task-table" class="usage-table">
483
+ <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>
484
+ <tbody id="usage-task-tbody"></tbody>
485
+ </table>
486
+ </div>
487
+ </div>
488
+ </div>
489
+ </section>
408
490
  <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
491
  </div>
410
492
  </main>
@@ -790,6 +872,105 @@ function settingRow(row, raise) {
790
872
  actions.append(save); if (row.type === 'roles') { const inherit = el('button', 'Use profile/shared roles'); inherit.type = 'button'; inherit.dataset.mutation = 'project'; inherit.disabled = managerMode && !snapshotValid; inherit.setAttribute('aria-label', 'Inherit ' + row.label); inherit.addEventListener('click', async () => { out.textContent = 'Restoring inherited role guidance...'; const accepted = await action({action:'setting',key:row.key,value:null}, inherit); out.textContent = accepted ? 'Inherited selection saved. Applies at the next peer start.' : $('notice').textContent; }); actions.append(inherit); } node.append(actions, out);
791
873
  return node;
792
874
  }
875
+ /* Structure (#689): tier 2 edits inside a settings session; tier 3 keys are text only, with the console command that changes them. */
876
+ const STRUCTURE_FIELDS = [['kind', 'text'], ['roles', 'text'], ['start_mode', 'text'], ['review_mode', 'text'], ['worktree', 'text'], ['permission_mode', 'text'], ['readOnly', 'text']];
877
+ const structureState = {revision: {}, lines: [], error: '', field: '', tier: 0, preview: null};
878
+ const structureList = (text) => String(text).split(',').map((item) => item.trim()).filter(Boolean);
879
+ function structureField(form, name, label, placeholder) {
880
+ const row = el('div', undefined, 'structure-field'), input = el('input'), id = 'structure-' + name;
881
+ const title = el('label', label, 'meta'); title.htmlFor = id; input.id = id; input.type = 'text'; input.placeholder = placeholder || ''; input.setAttribute('aria-label', label);
882
+ row.append(title, input); form.append(row); return input;
883
+ }
884
+ function structureResult(box, out) {
885
+ box.replaceChildren();
886
+ if (structureState.error) box.append(el('p', structureState.error + (structureState.field ? ' (' + structureState.field + ')' : ''), 'structure-error'));
887
+ if (structureState.lines.length) box.append(el('pre', structureState.lines.join('\n'), 'structure-diff'));
888
+ if (out) out(box);
889
+ }
890
+ function structureForm(cls, title, build, file, edit, raise) {
891
+ const form = el('div', undefined, cls), head = el('p', title, 'label'), fields = {}, result = el('div');
892
+ form.append(head);
893
+ build(form, fields);
894
+ const preview = el('button', 'Preview change', 'primary'); preview.type = 'button'; preview.dataset.mutation = 'project';
895
+ const reason = structureField(form, 'reason', 'Reason (recorded with the change)', 'why this change');
896
+ const actions = el('div', undefined, 'actions'); actions.append(preview);
897
+ form.append(actions, result);
898
+ const body = () => { const set = fields.set(); return set ? {file, revision: edit.revisions[file], set} : null; };
899
+ preview.addEventListener('click', async () => {
900
+ structureState.error = ''; structureState.field = ''; structureState.lines = [];
901
+ const request = body();
902
+ if (!request) { if (!structureState.error) structureState.error = 'Give a mode or the required checks.'; structureResult(result); return; }
903
+ try {
904
+ const reply = await post('/action', {action:'config_edit_preview', edit: request});
905
+ if (reply.ok === false) { structureState.error = reply.error || 'The hub refused this change.'; structureState.field = reply.field || ''; }
906
+ else { structureState.lines = (reply.lines || []).map(String); structureState.tier = reply.tier || 2; }
907
+ } catch (error) { structureState.error = error.message; }
908
+ structureResult(result, (box) => {
909
+ if (structureState.error || structureState.tier === 3) { if (!structureState.error) box.append(el('p', 'This key runs code or locates secrets: change it in the console, never here.', 'structure-error')); return; }
910
+ const write = el('button', 'Write change', 'primary'); write.type = 'button'; write.dataset.mutation = 'project';
911
+ write.addEventListener('click', async () => {
912
+ if (!reason.value.trim()) { notice('Give a reason before writing.', true); return; }
913
+ const done = await action({action:'config_edit_apply', edit: request, reason: reason.value.trim()}, write);
914
+ if (done) { structureState.lines = []; structureResult(result); }
915
+ });
916
+ box.append(write);
917
+ });
918
+ });
919
+ return form;
920
+ }
921
+ function renderStructure(target, edit, raise) {
922
+ const group = el('div', undefined, 'structure-group'), head = el('div', undefined, 'row-head');
923
+ head.append(el('span', 'Structure', 'label'));
924
+ if (edit.sdlc && edit.sdlc.pending) head.append(badge('restart pending', 'warn'));
925
+ group.append(head);
926
+ if (!raise) group.append(el('p', 'Read only here. Open a settings session with ahub ui --settings to change instances, review, snapshots, approvals and the lifecycle.', 'meta'));
927
+ for (const instance of edit.instances || []) {
928
+ const row = el('div', undefined, 'structure-instance'), line = Object.entries(instance.fields).map(([key, value]) => key + ': ' + (Array.isArray(value) ? value.join(', ') : String(value))).join(' / ');
929
+ row.append(el('p', instance.id, 'label'), el('p', line || 'no tier 2 fields set', 'meta'));
930
+ if (instance.tier3.length) row.append(el('p', instance.tier3.join(', ') + ': set in the console', 'meta'));
931
+ group.append(row);
932
+ }
933
+ for (const block of edit.blocks || []) group.append(el('p', block.path + ': ' + settingText(block.value), 'meta'));
934
+ const lifecycle = el('div', undefined, 'structure-sdlc');
935
+ lifecycle.append(el('p', 'Lifecycle (sdlc.toml): ' + edit.sdlc.mode + '; required checks: ' + (edit.sdlc.requiredChecks.length ? edit.sdlc.requiredChecks.join(', ') : 'none'), 'meta'));
936
+ group.append(lifecycle);
937
+ if (raise) {
938
+ lifecycle.append(structureForm('structure-sdlc-form', 'Change the lifecycle', (form, fields) => {
939
+ const mode = structureField(form, 'sdlc-mode', 'sdlc mode (off, advise, enforce)', edit.sdlc.mode), checks = structureField(form, 'sdlc-checks', 'required checks, comma list', edit.sdlc.requiredChecks.join(', '));
940
+ fields.set = () => { const set = []; if (mode.value.trim()) set.push({path:['mode'], value: mode.value.trim()}); if (checks.value.trim()) set.push({path:['required_checks'], value: structureList(checks.value)}); return set.length ? set : null; };
941
+ }, 'sdlc.toml', edit, raise));
942
+ const add = structureForm('structure-add', 'Add an instance, or change one field of an existing one (type clear to remove a field)', (form, fields) => {
943
+ const id = structureField(form, 'id', 'instance id, <kind>-<name>', 'codex-review');
944
+ const inputs = {}; for (const [name] of STRUCTURE_FIELDS) inputs[name] = structureField(form, name, name + (name === 'roles' ? ' (comma list)' : name === 'readOnly' ? ' (true or false)' : ''), '');
945
+ const convert = (name, text) => name === 'roles' ? structureList(text) : name === 'readOnly' ? text === 'true' : text;
946
+ fields.set = () => {
947
+ const name = id.value.trim(), known = (edit.known || []).includes(name), typed = [];
948
+ for (const [field] of STRUCTURE_FIELDS) { const text = inputs[field].value.trim(); if (text) typed.push([field, text]); }
949
+ if (!name) { structureState.error = 'An instance needs an id and a kind.'; return null; }
950
+ // readOnly is exactly true or false: anything else is the person's typo, never a silent false.
951
+ if (typed.some(([field, text]) => field === 'readOnly' && text !== 'true' && text !== 'false' && text !== 'clear')) { structureState.error = 'readOnly is true or false.'; return null; }
952
+ if (known) {
953
+ // An existing instance changes leaf by leaf: a field left blank keeps its value, and a tier 3 field it holds is never part of the request.
954
+ if (!typed.length) { structureState.error = 'Type the field to change, or clear to remove it.'; return null; }
955
+ return typed.map(([field, text]) => ({path:['instances', name, field], value: text === 'clear' ? null : convert(field, text)}));
956
+ }
957
+ const value = {}; for (const [field, text] of typed) { if (text !== 'clear') value[field] = convert(field, text); }
958
+ if (!value.kind) { structureState.error = 'An instance needs an id and a kind.'; return null; }
959
+ return [{path:['instances', name], value}];
960
+ };
961
+ }, 'config.local.json', edit, raise);
962
+ group.append(add);
963
+ }
964
+ const tier3 = el('div', undefined, 'structure-tier3');
965
+ tier3.append(el('p', 'Executable and secret-locating keys', 'label'), el('p', "Changing one runs code with the daemon's rights or chooses where secrets are read from. It is never done from a browser: use the console command.", 'meta'));
966
+ for (const item of edit.tier3 || []) { tier3.append(el('p', item.key + (item.set ? ' (set)' : ' (not set)'), 'meta'), el('code', item.command)); }
967
+ group.append(tier3);
968
+ const backups = el('div', undefined, 'structure-backups');
969
+ backups.append(el('p', 'Backups (the last ' + (edit.backups || []).length + ')', 'label'));
970
+ for (const item of edit.backups || []) backups.append(el('p', new Date(item.at).toISOString() + ' ' + item.file + ' ' + item.bytes + ' bytes', 'meta'));
971
+ group.append(backups);
972
+ target.append(group);
973
+ }
793
974
  const settingSlots = new Map();
794
975
  function setText(node, text) { if (node.textContent !== text) node.textContent = text; }
795
976
  function renderSettings(settings, roster = []) {
@@ -803,6 +984,7 @@ function renderSettings(settings, roster = []) {
803
984
  settingSlots.clear();
804
985
  const reload = el('div', undefined, 'settings-reload'); settingSlots.set('$reload', { node: reload, signature: null }); target.append(reload);
805
986
  const undo = el('div'); settingSlots.set('', { node: undo, signature: null }); target.append(undo);
987
+ const structure = el('div'); settingSlots.set('$structure', { node: structure, signature: null }); target.append(structure);
806
988
  for (const group of SETTING_GROUPS) {
807
989
  const rows = settings.rows.filter((row) => row.group === group);
808
990
  if (!rows.length) continue;
@@ -830,6 +1012,11 @@ function renderSettings(settings, roster = []) {
830
1012
  }
831
1013
  }
832
1014
  }
1015
+ const structureSlot = settingSlots.get('$structure'), structureSignature = JSON.stringify([settings.configEdit, raise]);
1016
+ if (structureSlot && structureSlot.signature !== structureSignature) {
1017
+ structureSlot.signature = structureSignature; structureSlot.node.replaceChildren();
1018
+ if (settings.configEdit && Array.isArray(settings.configEdit.instances)) renderStructure(structureSlot.node, settings.configEdit, raise);
1019
+ }
833
1020
  const undoSlot = settingSlots.get(''), undoKey = settings.undo || '';
834
1021
  if (undoSlot && undoSlot.signature !== undoKey) {
835
1022
  undoSlot.signature = undoKey; undoSlot.node.replaceChildren();
@@ -1028,6 +1215,37 @@ function dropTarget(node, accepted, payload) {
1028
1215
  void action({...payload, id:card.id});
1029
1216
  });
1030
1217
  }
1218
+ // #684 2c: the sheet's Code group. The hub classified the symbols (`task.codeGroup`, absent for a private task, so nothing renders for one);
1219
+ // 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.
1220
+ function codeGroupSection(task) {
1221
+ const group = task.codeGroup;
1222
+ if (!group) return null;
1223
+ const section = el('section', undefined, 'detail-group code-group'); section.append(el('h4', 'Code', 'detail-heading'));
1224
+ if (group.state !== 'known') { section.append(el('p', 'unknown (no code facts)', 'meta')); return section; }
1225
+ const list = el('ul', undefined, 'code-symbols');
1226
+ for (const symbol of group.symbols || []) {
1227
+ const item = el('li'), open = el('button', symbol.name, 'code-symbol ' + symbol.mark);
1228
+ open.type = 'button'; open.dataset.symbolNode = symbol.id; open.title = symbol.path + ' (' + symbol.mark + '); open in Memory';
1229
+ open.setAttribute('aria-label', symbol.name + ' in ' + symbol.path + ', ' + symbol.mark + '; open in Memory');
1230
+ open.addEventListener('click', () => openMemoryAt(symbol.id));
1231
+ item.append(open, el('span', ' ' + symbol.mark + ' / ' + symbol.path, 'meta'));
1232
+ list.append(item);
1233
+ }
1234
+ 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); }
1235
+ if (list.children.length) section.append(list); else section.append(el('p', 'none observed', 'meta'));
1236
+ const counts = group.counts || { matching: 0, unplanned: 0, untouched: 0 };
1237
+ const note = counts.matching + ' matching, ' + counts.unplanned + ' unplanned, ' + counts.untouched + ' untouched' + (group.head ? ' at ' + group.head : '');
1238
+ section.append(el('p', note + (group.more ? '; ' + group.more + ' more not shown' : '')
1239
+ + (group.planWithheld ? '; ' + group.planWithheld + ' plan name' + (group.planWithheld === 1 ? ' is' : 's are') + ' withheld, so a symbol marked unplanned may be in the plan' : '')
1240
+ + (group.complete ? '' : '; incomplete: an untouched symbol may have changed, and a symbol marked unplanned may be in the plan')
1241
+ + '; advisory: deleted symbols are not reported', 'meta'));
1242
+ return section;
1243
+ }
1244
+ function openMemoryAt(node) {
1245
+ if (typeof node !== 'string' || !node.startsWith('symbol:')) return;
1246
+ memoryQuery.node = node; memoryQuery.q = ''; $('memory-q').value = '';
1247
+ closeTaskDetail(); switchView('memory'); rerenderMemory();
1248
+ }
1031
1249
  function taskDetail(task, card, stage, ids, overdue) {
1032
1250
  const row = el('article', undefined, 'task-detail'), head = el('div', undefined, 'row-head');
1033
1251
  row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
@@ -1058,6 +1276,7 @@ function taskDetail(task, card, stage, ids, overdue) {
1058
1276
  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
1277
  if(task.pinned)links.append(el('p','Pinned','meta'));
1060
1278
  if (links.children.length > 1) row.append(links);
1279
+ const code = codeGroupSection(task); if (code) row.append(code);
1061
1280
  const controls = el('div', undefined, 'actions');
1062
1281
  if (card.pull && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Pull into To Do', {action:'backlog_pull',id:task.id}));
1063
1282
  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 +1447,171 @@ function renderNeedsAttention(snapshot) {
1228
1447
  const approvals=$('agents-approval-count');approvals.textContent=approvalCount?'('+approvalCount+')':'';approvals.hidden=!approvalCount;
1229
1448
  const title=value?.act?'('+value.act+') agent-hub':attentionBaseTitle;if(document.title!==title)document.title=title;
1230
1449
  }
1450
+ function formatCounter(c, exact) {
1451
+ if (!c || c.value === null || c.value === undefined) return 'unknown';
1452
+ const val = c.value;
1453
+ let str;
1454
+ if (exact || Math.abs(val) < 1000) str = String(val);
1455
+ else if (val >= 1e7) str = Math.round(val / 1e6) + 'M';
1456
+ else if (val >= 1e6) str = (val / 1e6).toFixed(1) + 'M';
1457
+ else if (val >= 1e4) str = Math.round(val / 1e3) + 'k';
1458
+ else str = (val / 1e3).toFixed(1) + 'k';
1459
+ const tag = c.source === 'count' ? 'hub' : c.source === 'partial' ? 'at least' : c.source;
1460
+ return str + (tag && tag !== 'native' ? ' (' + tag + ')' : '');
1461
+ }
1462
+ const USAGE_LIMIT_ROWS = 20;
1463
+ const USAGE_COVERAGE_NOTE = 'Coverage: Jcode and Kimi (prior to #285) report hub-counted increments; unrecorded sources are noted as unknown.';
1464
+ function renderUsage(usage, tasks, budget) {
1465
+ if (!usage) return;
1466
+ // A failed read is not a hidden view: replace the old figures with an explicit state so stale totals are never shown as current.
1467
+ if (usage.unavailable === true) {
1468
+ for (const id of ['usage-lifetime-total', 'usage-lifetime-nocache', 'usage-lifetime-cache']) $(id).textContent = 'unavailable';
1469
+ for (const id of ['usage-lifetime-total-sub']) $(id).textContent = '';
1470
+ $('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.';
1471
+ $('usage-coverage-note').classList.toggle('usage-behind', true);
1472
+ $('usage-daily-chart').replaceChildren(el('p', 'Usage unavailable.', 'empty'));
1473
+ $('usage-daily-legend').replaceChildren();
1474
+ $('usage-paused-alert').hidden = true;
1475
+ $('usage-limits-tbody').replaceChildren();
1476
+ $('usage-task-tbody').replaceChildren();
1477
+ $('usage-task-count').textContent = 'unavailable';
1478
+ return;
1479
+ }
1480
+ // #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.
1481
+ const coverage = $('usage-coverage-note');
1482
+ coverage.textContent = USAGE_COVERAGE_NOTE + (usage.behind === true ? ' Still folding the event log: figures are a lower bound until it catches up.' : '');
1483
+ coverage.classList.toggle('usage-behind', usage.behind === true);
1484
+ const total = usage.total;
1485
+ if (total) {
1486
+ const overall = total.overall;
1487
+ $('usage-lifetime-total').textContent = formatCounter(overall?.tokens);
1488
+ const unknownPeers = total.unknownPeers?.tokens || [];
1489
+ $('usage-lifetime-total-sub').textContent = unknownPeers.length ? 'Unknown: ' + unknownPeers.join(', ') : (total.since ? 'Since ' + total.since.slice(0, 10) : '');
1490
+ $('usage-lifetime-nocache').textContent = formatCounter(overall?.withoutCache);
1491
+ $('usage-lifetime-cache').textContent = formatCounter(overall?.cacheRead);
1492
+ }
1493
+ const daily = usage.daily;
1494
+ const chart = $('usage-daily-chart'), legend = $('usage-daily-legend');
1495
+ if (daily && daily.days && daily.days.length) {
1496
+ chart.replaceChildren();
1497
+ let maxDay = 1;
1498
+ for (const d of daily.days) {
1499
+ if (d.total?.value && d.total.value > maxDay) maxDay = d.total.value;
1500
+ }
1501
+ for (const d of daily.days) {
1502
+ const col = el('div', undefined, 'usage-day-col');
1503
+ col.append(el('span', d.empty === true ? 'no record' : formatCounter(d.total), 'usage-day-total'));
1504
+ const bar = el('div', undefined, 'usage-stacked-bar');
1505
+ bar.title = d.day + ': ' + (d.empty === true ? 'no record' : formatCounter(d.total));
1506
+ for (const p of daily.peers || []) {
1507
+ const c = d.peers[p];
1508
+ if (c && c.value && c.value > 0) {
1509
+ const seg = el('div', undefined, 'usage-bar-segment peer-' + p);
1510
+ const pct = Math.max(2, Math.round((c.value / maxDay) * 100));
1511
+ seg.style.height = pct + '%';
1512
+ seg.style.backgroundColor = 'var(--peer-color)';
1513
+ seg.title = p + ': ' + formatCounter(c);
1514
+ bar.append(seg);
1515
+ }
1516
+ }
1517
+ col.append(bar, el('span', d.day.slice(5), 'usage-day-label'));
1518
+ chart.append(col);
1519
+ }
1520
+ legend.replaceChildren();
1521
+ for (const p of daily.peers || []) {
1522
+ const item = el('span', undefined, 'usage-legend-item peer-' + p);
1523
+ const dot = el('span', undefined, 'peer-color-dot peer-' + p);
1524
+ dot.style.backgroundColor = 'var(--peer-color)';
1525
+ item.append(dot, el('span', p));
1526
+ legend.append(item);
1527
+ }
1528
+ } else {
1529
+ chart.replaceChildren(el('p', 'No daily token records yet.', 'empty'));
1530
+ legend.replaceChildren();
1531
+ }
1532
+
1533
+ const limits = usage.limits;
1534
+ const pausedAlert = $('usage-paused-alert');
1535
+ if (limits?.open?.length) {
1536
+ pausedAlert.hidden = false;
1537
+ pausedAlert.textContent = 'Active budget pauses: ' + limits.open.map(p => p.peer + ' (' + p.window + (p.resetsAt ? ', resets ' + rel(p.resetsAt) : '') + ')').join('; ');
1538
+ } else {
1539
+ pausedAlert.hidden = true;
1540
+ }
1541
+
1542
+ const limitsTbody = $('usage-limits-tbody');
1543
+ limitsTbody.replaceChildren();
1544
+ const history = limits?.history || [];
1545
+ // 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.
1546
+ const hidden = Math.max(0, history.length - USAGE_LIMIT_ROWS) + (Number.isFinite(limits?.omitted) ? limits.omitted : 0);
1547
+ const limitsNote = $('usage-limits-note');
1548
+ limitsNote.hidden = hidden === 0;
1549
+ 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).';
1550
+ if (history.length) {
1551
+ for (const h of history.slice(-USAGE_LIMIT_ROWS).reverse()) {
1552
+ const tr = el('tr');
1553
+ const timeCell = el('td', time(Date.parse(h.at)));
1554
+ timeCell.title = h.at;
1555
+ const typeCell = el('td', h.type.replace(/^budget_/, '').replace(/^quota_/, ''));
1556
+ const peerCell = el('td', h.peer);
1557
+ const winCell = el('td', h.window);
1558
+ let detailText = '';
1559
+ if (h.detail) {
1560
+ if (h.detail.resetsAt) detailText += 'resets ' + rel(Number(h.detail.resetsAt)) + ' ';
1561
+ if (h.detail.used !== undefined) detailText += Math.round(Number(h.detail.used) * 100) + '% ';
1562
+ if (h.detail.cause) detailText += '(' + h.detail.cause + ')';
1563
+ }
1564
+ const detailCell = el('td', detailText.trim() || '-');
1565
+ tr.append(timeCell, typeCell, peerCell, winCell, detailCell);
1566
+ limitsTbody.append(tr);
1567
+ }
1568
+ } else {
1569
+ const tr = el('tr');
1570
+ const td = el('td', 'No limit events recorded yet.', 'empty');
1571
+ td.colSpan = 5;
1572
+ tr.append(td);
1573
+ limitsTbody.append(tr);
1574
+ }
1575
+
1576
+ const taskTbody = $('usage-task-tbody');
1577
+ taskTbody.replaceChildren();
1578
+ const usageTasks = usage.tasks || [];
1579
+ const taskUsageMap = new Map(usageTasks.map(t => [t.id, t]));
1580
+ const boardTasks = tasks || [];
1581
+ const taskIds = new Set([...boardTasks.map(t => t.id), ...usageTasks.map(t => t.id)]);
1582
+ const sortedIds = [...taskIds].sort((a, b) => b - a);
1583
+ let taskCount = 0;
1584
+ for (const id of sortedIds) {
1585
+ if (id === 0) continue;
1586
+ const bt = boardTasks.find(t => t.id === id);
1587
+ const ut = taskUsageMap.get(id);
1588
+ // 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.
1589
+ const pii = ut?.pii === true || bt?.title === '[pii]';
1590
+ const tr = el('tr');
1591
+ const taskCell = el('td');
1592
+ if (pii) {
1593
+ taskCell.append(el('span', '#' + id + ' [pii]', 'usage-tag pii'));
1594
+ } else {
1595
+ taskCell.textContent = '#' + id + (bt?.title ? ' ' + bt.title : '');
1596
+ }
1597
+ const stateCell = el('td', bt?.state ? bt.state.replaceAll('_', ' ') : '-');
1598
+ const ownerCell = el('td', bt?.owner || '-');
1599
+ const prNum = bt?.lifecycle?.pr || bt?.sdlc?.pr;
1600
+ const prCell = el('td', prNum ? '#' + prNum : (bt?.refs?.branch || '-'));
1601
+
1602
+ const inputCell = el('td', formatCounter(ut?.input), 'num');
1603
+ const outputCell = el('td', formatCounter(ut?.output), 'num');
1604
+ const crCell = el('td', formatCounter(ut?.cacheRead), 'num');
1605
+ const cwCell = el('td', formatCounter(ut?.cacheWrite), 'num');
1606
+ // `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.
1607
+ const totalCell = el('td', formatCounter(ut?.total?.value != null ? ut.total : ut?.counted), 'num');
1608
+
1609
+ tr.append(taskCell, stateCell, ownerCell, prCell, inputCell, outputCell, crCell, cwCell, totalCell);
1610
+ taskTbody.append(tr);
1611
+ taskCount++;
1612
+ }
1613
+ $('usage-task-count').textContent = taskCount + ' tasks';
1614
+ }
1231
1615
  function render(snapshot) {
1232
1616
  renderNeedsAttention(snapshot);
1233
1617
  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 +1693,7 @@ function render(snapshot) {
1309
1693
  renderBench(snapshot.bench);
1310
1694
  renderMemory(snapshot.memory);
1311
1695
  renderSettings(snapshot.settings, snapshot.roster);
1696
+ renderUsage(snapshot.usage, snapshot.tasks, snapshot.budget);
1312
1697
  const stream = $('stream');
1313
1698
  if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
1314
1699
  for (const entry of snapshot.events || []) {
@@ -1333,7 +1718,7 @@ async function poll() {
1333
1718
  const captured = context();
1334
1719
  if (managerMode && (!captured.projectId || !captured.instanceId)) { if (!stopped) timer = setTimeout(poll, 1000); return; }
1335
1720
  try {
1336
- const input = {after:cursor, memory:memoryInput()}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
1721
+ const input = {after:cursor, memory:memoryInput(), view:currentView}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
1337
1722
  const result = await post('/snapshot', input);
1338
1723
  if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
1339
1724
  snapshotValid = true; lastSnapshot = result; lastSnapshotAt = Date.now(); render(result); setConnection('Live / updated ' + new Date().toLocaleTimeString(), 'live'); setDisconnected(false);