@staix/agent-hub 0.30.0 → 0.31.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 (125) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +9 -4
  7. package/docs/agent-notes/daemon.md +15 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
package/src/rooms/wire.ts CHANGED
@@ -23,7 +23,7 @@ const FP = /^[0-9a-f]{64}$/;
23
23
  export interface RoomMember extends PublicHubIdentity { label: string; exposedPeers: string[] }
24
24
  export interface FrameWire {
25
25
  v: 1; type: "frame"; room: string; epoch: string; sender: string;
26
- recipients: string[]; seq: string; nonce: string; ciphertext: string; signature: string;
26
+ recipients: string[]; seq: string; ephemeral?: true; nonce: string; ciphertext: string; signature: string;
27
27
  }
28
28
  export interface RosterWire {
29
29
  v: 1; type: "roster"; room: string; epoch: string; owner: string;
@@ -107,8 +107,10 @@ function wrap(x: unknown): WrappedEpochKey {
107
107
  return { v: 1, owner: fingerprint(o.owner), recipient: fingerprint(o.recipient), ephemeralKey: b64(o.ephemeralKey, 32), salt: b64(o.salt, 32), nonce: b64(o.nonce, 12), ciphertext: ciphertext(o.ciphertext, 48, 48), signature: b64(o.signature, 64) };
108
108
  }
109
109
  function parseFrame(x: unknown): FrameWire {
110
- const o = object(x, ["v", "type", "room", "epoch", "sender", "recipients", "seq", "nonce", "ciphertext", "signature"]); base(o, "frame");
111
- return { v: 1, type: "frame", room: b64(o.room, 32), epoch: positiveU64(o.epoch), sender: fingerprint(o.sender), recipients: sorted(array(o.recipients, 64, fingerprint, true), x => x), seq: positiveU64(o.seq), nonce: b64(o.nonce, 12), ciphertext: ciphertext(o.ciphertext, 65552), signature: b64(o.signature, 64) };
110
+ const ephemeral = typeof x === "object" && x !== null && Object.hasOwn(x, "ephemeral");
111
+ const o = object(x, ["v", "type", "room", "epoch", "sender", "recipients", "seq", "nonce", "ciphertext", "signature", ...(ephemeral ? ["ephemeral"] : [])]); base(o, "frame");
112
+ if (ephemeral && o.ephemeral !== true) fail();
113
+ return { v: 1, type: "frame", room: b64(o.room, 32), epoch: positiveU64(o.epoch), sender: fingerprint(o.sender), recipients: sorted(array(o.recipients, 64, fingerprint, true), x => x), seq: positiveU64(o.seq), ...(ephemeral ? { ephemeral: true as const } : {}), nonce: b64(o.nonce, 12), ciphertext: ciphertext(o.ciphertext, 65552), signature: b64(o.signature, 64) };
112
114
  }
113
115
  function parseRoster(x: unknown): RosterWire {
114
116
  const o = object(x, ["v", "type", "room", "epoch", "owner", "members", "wraps", "privateNonce", "privateCiphertext", "signature"]); base(o, "roster");
@@ -228,6 +230,7 @@ export interface VerifiedPublicFrame {
228
230
  readonly digest: string;
229
231
  readonly rosterDigest: string;
230
232
  readonly recipients: readonly string[];
233
+ readonly ephemeral?: true;
231
234
  }
232
235
  interface PublicFrameState { wire: Uint8Array }
233
236
  const publicFrames = new WeakMap<VerifiedPublicFrame, PublicFrameState>();
@@ -252,7 +255,7 @@ export async function verifyPublicFrame(input: { wire: Uint8Array; roster: Verif
252
255
  await verifyFrameSignature(frame, sender);
253
256
  const frameDigest = await digest(canonicalBytes(frame));
254
257
  const proof = Object.freeze({ room: frame.room, epoch: frame.epoch, sender: frame.sender, seq: frame.seq,
255
- digest: frameDigest, rosterDigest: roster.digest, recipients: Object.freeze([...frame.recipients]) });
258
+ digest: frameDigest, rosterDigest: roster.digest, ...(frame.ephemeral ? { ephemeral: true as const } : {}), recipients: Object.freeze([...frame.recipients]) });
256
259
  publicFrames.set(proof, { wire });
257
260
  return proof;
258
261
  }
@@ -330,9 +333,10 @@ function frameAad(frame: Pick<FrameWire, "room" | "epoch" | "sender" | "seq">):
330
333
  return enc.encode(`${frame.room}|${frame.epoch}|${frame.sender}|${frame.seq}`);
331
334
  }
332
335
  export async function sealFrame(input: {
333
- epoch: VerifiedEpoch; identity: HubIdentity; recipients: string[]; plaintext: Uint8Array; sequence: SequencePort;
336
+ epoch: VerifiedEpoch; identity: HubIdentity; recipients: string[]; plaintext: Uint8Array; sequence: SequencePort; ephemeral?: true;
334
337
  }): Promise<Uint8Array> {
335
338
  await assertRoomCryptoReady();
339
+ if (input.ephemeral !== undefined && input.ephemeral !== true) fail();
336
340
  const epoch = input.epoch, identity = input.identity, sequence = input.sequence;
337
341
  const current = state(epoch), sender = identity.public.fingerprint;
338
342
  const member = current.roster.members.find(m => m.fingerprint === sender);
@@ -344,7 +348,7 @@ export async function sealFrame(input: {
344
348
  if (!sequence || typeof sequence.reserve !== "function" || typeof sequence.isCurrent !== "function") fail("state_unavailable");
345
349
  const binding = { room: epoch.room, sender, epoch: epoch.epoch, rosterDigest: epoch.digest };
346
350
  const seq = positiveU64(await portCall(() => sequence.reserve({ ...binding })));
347
- const header = { v: 1 as const, type: "frame" as const, room: epoch.room, epoch: epoch.epoch, sender, recipients, seq };
351
+ const header = { v: 1 as const, type: "frame" as const, room: epoch.room, epoch: epoch.epoch, sender, recipients, seq, ...(input.ephemeral ? { ephemeral: true as const } : {}) };
348
352
  const nonce = randomBytes(12);
349
353
  const encrypted = await encryptEpoch({ key: current.key, purpose: "frame", context: frameContext(epoch, sender), aad: frameAad(header), nonce, plaintext });
350
354
  const body = { ...header, nonce: encodeBase64url(nonce), ciphertext: encodeBase64url(encrypted) };
@@ -354,6 +358,20 @@ export async function sealFrame(input: {
354
358
  if (fresh !== true) fail("state_changed");
355
359
  return wire;
356
360
  }
361
+ /** Read a retained local send for resealing, never receiver admission or replay evidence. */
362
+ export async function openOwnFrame(input: { wire: Uint8Array; identity: HubIdentity; epoch: VerifiedEpoch }): Promise<Uint8Array> {
363
+ await assertRoomCryptoReady();
364
+ const frame = decodeFrame(input.wire), current = state(input.epoch), sender = input.identity.public;
365
+ const member = current.roster.members.find(value => value.fingerprint === sender.fingerprint);
366
+ if (frame.ephemeral || frame.room !== input.epoch.room || frame.epoch !== input.epoch.epoch || frame.sender !== sender.fingerprint
367
+ || !member || member.signingKey !== sender.signingKey || member.agreementKey !== sender.agreementKey
368
+ || frame.recipients.some(recipient => !current.roster.members.some(value => value.fingerprint === recipient))) fail("untrusted");
369
+ await verifyFrameSignature(frame, member);
370
+ const plaintext = await decryptEpoch({ key: current.key, purpose: "frame", context: frameContext(input.epoch, frame.sender), aad: frameAad(frame),
371
+ nonce: decodeBase64url(frame.nonce, 12), ciphertext: decodeBase64url(frame.ciphertext) });
372
+ if (plaintext.length > ROOM_WIRE_LIMITS.plaintext) fail("limit");
373
+ return plaintext;
374
+ }
357
375
  export type FrameOpenResult = { status: "accepted"; plaintext: Uint8Array; gap: string } | { status: "duplicate" | "stale" };
358
376
  export async function openFrame(input: {
359
377
  wire: Uint8Array; identity: HubIdentity; current: VerifiedEpoch;
package/src/ui/index.html CHANGED
@@ -450,7 +450,7 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
450
450
  <section id="office-section" aria-labelledby="office-title" data-view="office" hidden><div class="section-head"><h2 id="office-title">Office</h2><div class="filters"><label for="office-view">Office mode</label><select id="office-view"><option value="off">Off</option><option value="flat">Flat</option><option value="isometric">Isometric</option></select></div></div><div id="office" class="content"></div></section>
451
451
  <section aria-labelledby="stream-title" data-view="stream" hidden><div class="section-head"><h2 id="stream-title">Live stream</h2><div class="filters"><div id="stream-filters" class="chips" role="group" aria-label="Filter events by kind"></div><label><input id="follow" type="checkbox" checked> Follow latest</label></div></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div><p id="stream-empty" class="empty" hidden>No events match this filter.</p></section>
452
452
 
453
- <section aria-labelledby="memory-title" data-view="memory" hidden><div class="section-head"><h2 id="memory-title">Memory</h2><div class="filters"><div id="memory-tiers" class="chips" role="group" aria-label="Memory tier"></div><span id="memory-count" class="meta"></span></div></div><div class="memory-controls"><input id="memory-q" type="search" maxlength="200" placeholder="Search memory" aria-label="Search memory"><select id="memory-kind" aria-label="Filter memory by kind"><option value="">All kinds</option><option value="decision">Decisions</option><option value="contract">Contracts</option><option value="fail">Fails</option><option value="finding">Findings</option></select><select id="memory-peer" aria-label="Filter memory by peer"><option value="">All peers</option></select><select id="memory-machine" aria-label="Filter memory by machine"><option value="">All machines</option></select><select id="memory-since" aria-label="Filter memory by time"><option value="">Any time</option><option value="1">Last day</option><option value="7">Last week</option><option value="30">Last 30 days</option></select><select id="memory-hops" aria-label="Graph neighbourhood depth"><option value="1">1 hop</option><option value="2">2 hops</option></select><label class="memory-code-toggle"><input id="memory-code-neighbours" type="checkbox" aria-describedby="memory-code-help" disabled>Code neighbours (1 hop)</label><span id="memory-code-help" class="meta">Select a symbol to inspect derived code neighbours.</span></div><div id="memory-list" class="task-list" aria-label="Memory entries"></div><div id="memory-graph" class="memory-graph"></div><div id="memory-detail"></div></section>
453
+ <section aria-labelledby="memory-title" data-view="memory" hidden><div class="section-head"><h2 id="memory-title">Memory</h2><div class="filters"><div id="memory-tiers" class="chips" role="group" aria-label="Memory tier"></div><span id="memory-count" class="meta"></span></div></div><p id="memory-sync-status" class="meta" aria-live="polite"></p><div class="memory-controls"><input id="memory-q" type="search" maxlength="200" placeholder="Search memory" aria-label="Search memory"><select id="memory-kind" aria-label="Filter memory by kind"><option value="">All kinds</option><option value="decision">Decisions</option><option value="contract">Contracts</option><option value="fail">Fails</option><option value="finding">Findings</option></select><select id="memory-peer" aria-label="Filter memory by peer"><option value="">All peers</option></select><select id="memory-machine" aria-label="Filter memory by machine"><option value="">All machines</option></select><select id="memory-since" aria-label="Filter memory by time"><option value="">Any time</option><option value="1">Last day</option><option value="7">Last week</option><option value="30">Last 30 days</option></select><select id="memory-hops" aria-label="Graph neighbourhood depth"><option value="1">1 hop</option><option value="2">2 hops</option></select><label class="memory-code-toggle"><input id="memory-code-neighbours" type="checkbox" aria-describedby="memory-code-help" disabled>Code neighbours (1 hop)</label><span id="memory-code-help" class="meta">Select a symbol to inspect derived code neighbours.</span></div><div id="memory-list" class="task-list" aria-label="Memory entries"></div><div id="memory-graph" class="memory-graph"></div><div id="memory-detail"></div></section>
454
454
  <section aria-labelledby="compose-title" data-view="message" hidden><div class="section-head"><h2 id="compose-title">Console message</h2></div><form id="send" class="content"><div class="field"><label for="recipient">Recipient</label><select id="recipient" name="recipient"><option value="">All peers</option></select></div><div class="field"><label for="message">Message</label><textarea id="message" name="body" required maxlength="8000" placeholder="Send an instruction to the team..."></textarea></div><button class="primary" data-mutation="project" type="submit">Send message</button></form></section>
455
455
  <section aria-labelledby="settings-title" data-view="settings" hidden><div class="section-head"><h2 id="settings-title">Settings</h2><span id="settings-session" class="meta" role="status"></span></div><div id="settings"><p class="empty">Waiting for the hub...</p></div></section>
456
456
  </div>
@@ -459,6 +459,7 @@ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity
459
459
  <section id="schedules-section" aria-labelledby="schedules-title" data-view="agents" hidden><div class="section-head"><h2 id="schedules-title">Schedules</h2><span id="schedule-count" class="meta"></span></div><div id="schedules"></div></section>
460
460
  <section id="rooms-section" aria-labelledby="rooms-title" data-view="agents" data-empty="true" hidden><div class="section-head"><h2 id="rooms-title">Rooms</h2><span id="room-count" class="meta"></span></div><div id="rooms"></div></section>
461
461
  <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>
462
+ <section id="results-section" aria-labelledby="results-title" data-view="agents" hidden><div class="section-head"><h2 id="results-title">Results</h2><span id="results-count" class="meta"></span></div><div id="results"></div></section>
462
463
  <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>
463
464
  <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>
464
465
  <div id="usage-content" class="content">
@@ -574,6 +575,7 @@ function initializeDashboardNavigation() {
574
575
  let ticket = location.hash.slice(1);
575
576
  history.replaceState(null, '', location.pathname);
576
577
  const $ = (id) => document.getElementById(id);
578
+ const reviewKit = null;
577
579
  const el = (tag, text, cls) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; };
578
580
  let stopped = false, cursor = 0, timer, projectsTimer, eventCount = 0;
579
581
  const setConnection = (text, state) => { const node = $('connection'); node.textContent = text; node.dataset.state = state; };
@@ -661,6 +663,10 @@ async function action(payload, button, managerAction = false) {
661
663
  return false;
662
664
  }
663
665
  if (key && pendingStarts.get(key) === pending) clearPendingStart(key);
666
+ if (['review_comment_put','review_comment_delete','review_comment_send'].includes(payload.action)) {
667
+ reviewCommentsCache.delete(reviewCommentsCacheKey(boardViewState?.snapshot.tasks?.find(task => task.id === payload.task), captured));
668
+ if (taskDetailId === payload.task) void refreshReviewComments(taskDetailId);
669
+ }
664
670
  if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Action accepted.');
665
671
  if ((payload.action === 'sdlc_queue' || payload.action === 'sdlc_release') && (stopped || captured.generation !== viewGeneration || captured.projectId !== selectedProjectId || captured.instanceId !== selectedInstanceId)) return false;
666
672
  return (payload.action === 'sdlc_queue' || payload.action === 'sdlc_release') ? result.report : true;
@@ -695,7 +701,7 @@ const firstLoad = Object.fromEntries(['tasks', 'peers'].map((name) => [name, [..
695
701
  function resetView(id, instance) {
696
702
  saveDraft();
697
703
  selectedProjectId = id || ''; selectedInstanceId = instance || ''; snapshotValid = false; cursor = 0; eventCount = 0; viewGeneration++;
698
- lastSnapshot = null;
704
+ lastSnapshot = null; reviewCommentsCache.clear();
699
705
  officePrev = null; officeDesks.clear(); officeCards.clear(); officeScene = null;
700
706
  $('office').replaceChildren(); $('office-section').hidden = true;
701
707
  closeTaskDetail(); renderNeedsAttention(null); applyDashboardView();
@@ -1102,6 +1108,7 @@ function keyedStart(name, enter = false) {
1102
1108
  }
1103
1109
  const BOARD_FOCUS_COLUMNS = ['backlog','todo','progress','review','done'];
1104
1110
  let columnFocus, taskDetailId = null;
1111
+ const reviewCommentsCache = new Map();
1105
1112
  let planningEdit = null, releaseEdit = null;
1106
1113
  function isClosedTaskState(state) { return state === 'approved' || state === 'cancelled'; }
1107
1114
  const loadedPlanningDates = new Map();
@@ -1281,6 +1288,57 @@ function deliverablesSection(task) {
1281
1288
  for (const file of d.files) list.append(el('li', (file.path || 'private file') + ' [' + file.format + (file.version ? ' ' + file.version : '') + '] ' + file.bytes + ' B, sha256 ' + file.sha256.slice(0, 12), 'meta'));
1282
1289
  section.append(list); return section;
1283
1290
  }
1291
+ function reviewCommentsSection(task) {
1292
+ const expectedReview = boardContext(), section = el('div', undefined, 'detail-group review-comments'); section.setAttribute('role', 'region'); section.setAttribute('aria-label', 'Review comments'); section.append(el('h4', 'Review comments', 'detail-heading'));
1293
+ const view = boardViewState?.snapshot.reviewComments, current = view?.task === task.id ? view : null;
1294
+ const counts = current?.counts || task.reviewComments;
1295
+ section.append(el('p', counts ? ['draft','sent','resolved','dismissed'].map(key => (counts[key] || 0) + ' ' + key).join(' / ') : 'No review comments.', 'meta'));
1296
+ const writable = !!reviewKit && boardViewState?.snapshot.reviewWritable === true;
1297
+ const refresh = el('button', 'Refresh review comments'); refresh.type = 'button'; refresh.onclick = () => void refreshReviewComments(task.id); section.append(refresh);
1298
+ if (current?.unavailable) section.append(el('p', 'Review comments unavailable. Refresh to try again.', 'meta warn'));
1299
+ if (writable) for (const pkg of current?.packages || []) if (pkg.delivery === 'unconfirmed') {
1300
+ const retry = el('button', 'Retry saved package (' + pkg.count + ' comments)'); retry.type = 'button'; retry.dataset.mutation = 'project';
1301
+ retry.onclick = () => sameBoardContext(expectedReview) && void action({action:'review_comment_send',task:task.id,packageId:pkg.id},retry);
1302
+ section.append(retry);
1303
+ }
1304
+ for (const comment of current?.comments || []) {
1305
+ const thread = comment.thread, id = thread?.id || comment.id, item = el('article', undefined, 'detail-group');
1306
+ const placement = comment.placement?.status || 'unavailable', range = thread && (placement === 'anchored' || placement === 'moved') ? comment.placement?.range || thread.anchor.selectors.find(selector => selector.kind === 'lineRange') : undefined;
1307
+ item.append(el('p', (thread ? thread.anchor.path + (range ? ':' + range.startLine + '-' + range.endLine : '') : 'Comment ' + id) + ' (' + placement + ') / ' + comment.lifecycle, 'meta'));
1308
+ if (thread) item.append(el('p', thread.body), el('p', thread.author + ' / ' + thread.at, 'meta'));
1309
+ if (writable) {
1310
+ if (comment.lifecycle === 'draft' && thread) {
1311
+ const edit = el('textarea'); edit.value = thread.body; edit.maxLength = 2048; edit.setAttribute('aria-label', 'Edit review comment ' + thread.id);
1312
+ const save = el('button', 'Save draft'); save.type='button'; save.dataset.mutation='project';
1313
+ save.onclick = () => sameBoardContext(expectedReview) && void action({action:'review_comment_put',task:task.id,id:thread.id,revision:comment.revision,commit:comment.commit,anchor:thread.anchor,body:edit.value},save);
1314
+ item.append(edit,save);
1315
+ }
1316
+ if (comment.lifecycle !== 'dismissed') item.append(boardButton(comment.lifecycle === 'draft' ? 'Delete draft' : 'Dismiss', {action:'review_comment_delete',task:task.id,id,revision:comment.revision}));
1317
+ }
1318
+ section.append(item);
1319
+ }
1320
+ if (!writable) section.append(el('p', 'Read-only here. Open this task in a current acon console to comment and send.', 'meta'));
1321
+ else {
1322
+ if (counts?.draft) section.append(boardButton('Send draft comments', {action:'review_comment_send',task:task.id}));
1323
+ const label = el('label', 'File to review'), path = el('input'); path.setAttribute('aria-label','Task file path to review'); path.placeholder = 'path/to/document.md'; label.append(path);
1324
+ const mount = el('div'), open = el('button', 'Open review file'); open.type = 'button';
1325
+ const expected = boardContext();
1326
+ open.addEventListener('click', async () => {
1327
+ if (!sameBoardContext(expected)) return;
1328
+ open.disabled = true;
1329
+ try {
1330
+ const result = await reviewKit.snapshot({panel:'review_blob',projectId:selectedProjectId,instanceId:selectedInstanceId,task:task.id,path:path.value,side:'head'});
1331
+ if (!sameBoardContext(expected) || taskDetailId !== task.id) return;
1332
+ if (result.ok === false || !result.doc) throw new Error(result.error || 'Review material unavailable.');
1333
+ mount.replaceChildren(reviewKit.render({doc:result.doc,threads:(current?.comments || []).filter(comment => comment.thread && comment.thread.anchor.path === result.doc.path && comment.thread.anchor.side !== 'base').map(comment => comment.thread),
1334
+ onComment: ({anchor,body}) => sameBoardContext(expected) && action({action:'review_comment_put',task:task.id,commit:result.head,anchor,body})}));
1335
+ } catch (error) { if (sameBoardContext(expected)) notice(error.message, true); }
1336
+ finally { open.disabled = false; }
1337
+ });
1338
+ section.append(label,open,mount);
1339
+ }
1340
+ return section;
1341
+ }
1284
1342
  function taskDetail(task, card, stage, ids, overdue) {
1285
1343
  const row = el('article', undefined, 'task-detail'), head = el('div', undefined, 'row-head');
1286
1344
  row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
@@ -1317,6 +1375,7 @@ function taskDetail(task, card, stage, ids, overdue) {
1317
1375
  if (links.children.length > 1) row.append(links);
1318
1376
  const code = codeGroupSection(task); if (code) row.append(code);
1319
1377
  const delivered = deliverablesSection(task); if (delivered) row.append(delivered);
1378
+ row.append(reviewCommentsSection(task));
1320
1379
  const controls = el('div', undefined, 'actions');
1321
1380
  if (card.pull && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Pull into To Do', {action:'backlog_pull',id:task.id}));
1322
1381
  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'}));
@@ -1368,7 +1427,7 @@ function setColumnFocus(column) {
1368
1427
  if (boardViewState) renderBoard(boardViewState.snapshot, boardViewState.peers, boardViewState.ids);
1369
1428
  }
1370
1429
  function closeTaskDetail() {
1371
- taskDetailId = null; const dialog = $('task-detail-dialog'); if (dialog.open) dialog.close();
1430
+ taskDetailId = null; reviewCommentsCache.clear(); const dialog = $('task-detail-dialog'); if (dialog.open) dialog.close();
1372
1431
  $('task-detail-content').replaceChildren(); signatures.delete('task-detail-content');
1373
1432
  }
1374
1433
  function refreshTaskDetail() {
@@ -1379,13 +1438,29 @@ function refreshTaskDetail() {
1379
1438
  const ids = boardViewState.ids.filter(id => ['idle','busy'].includes(boardViewState.peers[id]?.state) && boardViewState.peers[id]?.attached !== false && boardViewState.peers[id]?.rostered !== false);
1380
1439
  const overdue = task.due != null && task.due < Date.now() && !isClosedTaskState(task.state);
1381
1440
  $('task-detail-title').textContent = 'Task #' + task.id;
1382
- update('task-detail-content', [task, card, stage, ids, overdue, boardContext()], target => target.append(taskDetail(task, card, stage, ids, overdue)));
1441
+ update('task-detail-content', [task, card, stage, ids, overdue, boardContext(), snapshot.reviewComments, snapshot.reviewWritable], target => target.append(taskDetail(task, card, stage, ids, overdue)));
1383
1442
  }
1384
1443
  function openTaskDetail(id) {
1385
1444
  if (stopped || !Number.isSafeInteger(id) || !snapshotValid || !boardViewState?.cards.has(id)) return;
1386
- switchView('board'); taskDetailId = id; refreshTaskDetail();
1445
+ if (taskDetailId !== id) reviewCommentsCache.clear();
1446
+ switchView('board'); taskDetailId = id; refreshTaskDetail(); void refreshReviewComments(id);
1387
1447
  const dialog = $('task-detail-dialog'); if (taskDetailId !== null && !dialog.open) dialog.showModal();
1388
1448
  }
1449
+ function reviewCommentsCacheKey(task, ctx = context()) {
1450
+ return JSON.stringify([ctx.projectId,ctx.instanceId,ctx.generation,task?.id ?? null,task?.reviewStateKey ?? null]);
1451
+ }
1452
+ async function refreshReviewComments(id) {
1453
+ if (stopped || taskDetailId !== id || !snapshotValid) return;
1454
+ const captured = context(), task = lastSnapshot?.tasks?.find(row => row.id === id), cacheKey = reviewCommentsCacheKey(task, captured), input = {after:cursor, view:'board', reviewTask:id, reviewRefresh:true};
1455
+ if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
1456
+ try {
1457
+ const result = await post('/snapshot', input);
1458
+ if (taskDetailId !== id || captured.generation !== viewGeneration || (managerMode && (result.projectId !== captured.projectId || result.instanceId !== captured.instanceId))
1459
+ || reviewCommentsCacheKey(lastSnapshot?.tasks?.find(row => row.id === id), captured) !== cacheKey) return;
1460
+ reviewCommentsCache.set(cacheKey, result.reviewComments || {task:id, unavailable:true});
1461
+ if (lastSnapshot) { lastSnapshot = {...lastSnapshot, reviewComments:reviewCommentsCache.get(cacheKey)}; render(lastSnapshot); }
1462
+ } catch (error) { if (taskDetailId === id) notice('Review comments unavailable: ' + error.message, true); }
1463
+ }
1389
1464
  function renderBoard(snapshot, peers, ids) {
1390
1465
  if (columnFocus === undefined) {
1391
1466
  columnFocus = dashboardCookie.get('agent-hub-column-focus') || null;
@@ -1855,8 +1930,9 @@ function render(snapshot) {
1855
1930
  }
1856
1931
  });
1857
1932
  renderBench(snapshot.bench);
1933
+ renderResults(snapshot.results);
1858
1934
  renderWorktrees(snapshot.worktrees);
1859
- renderMemory(snapshot.memory);
1935
+ renderMemory(snapshot.memory, snapshot.memorySync);
1860
1936
  renderSettings(snapshot.settings, snapshot.roster);
1861
1937
  renderUsage(snapshot.usage, snapshot.tasks, snapshot.budget);
1862
1938
  renderSchedules(snapshot.schedules);
@@ -1888,6 +1964,9 @@ async function poll() {
1888
1964
  const input = {after:cursor, memory:memoryInput(), view:currentView}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
1889
1965
  const result = await post('/snapshot', input);
1890
1966
  if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
1967
+ const detailTask = taskDetailId === null ? undefined : result.tasks?.find(task => task.id === taskDetailId), detailKey = reviewCommentsCacheKey(detailTask, captured);
1968
+ if (taskDetailId !== null && reviewCommentsCache.has(detailKey)) result.reviewComments = reviewCommentsCache.get(detailKey);
1969
+ else if (taskDetailId !== null) reviewCommentsCache.clear();
1891
1970
  snapshotValid = true; lastSnapshot = result; lastSnapshotAt = Date.now(); render(result); setConnection('Live / updated ' + new Date().toLocaleTimeString(), 'live'); setDisconnected(false);
1892
1971
  }
1893
1972
  }
@@ -2371,6 +2450,35 @@ let streamFilter = '';
2371
2450
  function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
2372
2451
  function refreshStreamEmpty() { const has = $('stream').querySelector('.event:not([hidden])') !== null, any = $('stream').querySelector('.event') !== null; $('stream-empty').hidden = has || !any; }
2373
2452
  // #251: benchmark runs on this machine and arm comparisons per suite. Every bar carries its number as text.
2453
+ /* #1054 T6: unread results, the runs a person started that settled and have not been seen. The snapshot only READS them: building this list never marks one read (a poll is not a person
2454
+ looking). A receipt is a person's click: Mark read (one), Mark all read, or Mark last unread again (relights the result read last; it lives here and in `ahub results --unread-last`, never in
2455
+ the acon inbox, because a read item leaves the inbox). The title is `<peer>: <outcome word>` and the detail is what the hub built at display (a stub when private, nothing when the request is no
2456
+ longer held); text is set with textContent only. The acon seeing rule (focus, visibility, a second in view) is the shell's and is not here: this panel's marks are explicit clicks. */
2457
+ function renderResults(results) {
2458
+ const unread = results && Array.isArray(results.unread) ? results.unread : [];
2459
+ const more = results && Number.isSafeInteger(results.moreUnread) ? results.moreUnread : 0;
2460
+ $('results-count').textContent = results ? unread.length + more + ' unread' : '';
2461
+ update('results', results === undefined ? null : results, (target) => {
2462
+ if (results === undefined) return empty(target, 'Results are shown to the person on a current snapshot.');
2463
+ const tools = el('div', undefined, 'row-head');
2464
+ if (unread.length) tools.append(button('Mark all read', {action: 'results_read_all'}));
2465
+ if (results.canUnread) tools.append(button('Mark last unread again', {action: 'results_unread_last'}));
2466
+ if (!unread.length) target.append(el('p', 'Nothing unread.', 'meta'));
2467
+ for (const item of unread) {
2468
+ const block = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
2469
+ head.append(el('span', String(item.title), 'label'));
2470
+ head.append(badge(String(item.outcome), item.outcome === 'done' ? 'ok' : item.outcome === 'failed' ? 'err' : 'warn'));
2471
+ const stamp = el('span'); if (item.settledAt) { stamp.dataset.ts = String(item.settledAt); stamp.title = time(item.settledAt); stamp.textContent = 'settled ' + rel(item.settledAt); } else stamp.textContent = ''; head.append(stamp);
2472
+ if (Number.isFinite(item.ms)) head.append(el('span', Math.round(item.ms / 1000) + 's', 'meta'));
2473
+ head.append(button('Mark read', {action: 'result_seen', id: String(item.id)}));
2474
+ block.append(head);
2475
+ if (item.detail && (item.detail.kind === 'head' || item.detail.kind === 'stub') && typeof item.detail.text === 'string') block.append(el('p', item.detail.text, 'meta'));
2476
+ target.append(block);
2477
+ }
2478
+ if (more > 0) target.append(el('p', more + ' older unread; run ahub results --unread to list them all.', 'meta'));
2479
+ target.append(tools);
2480
+ });
2481
+ }
2374
2482
  /* #1057: the worktree shelf. A panel refresh reads nothing from GitHub: the rows come from the hub's cached facts, and only the explicit 'Refresh PR states' button asks it to read PR states. Removal is never a button: the panel shows the command to run in a terminal. */
2375
2483
  function renderWorktrees(shelf) {
2376
2484
  const live = shelf && Array.isArray(shelf.live) ? shelf.live : [], settled = shelf && Array.isArray(shelf.settled) ? shelf.settled : [];
@@ -2552,7 +2660,30 @@ function layout(graph) {
2552
2660
  return pos;
2553
2661
  }
2554
2662
  function selectMemoryNode(node) { memoryQuery.node = memoryQuery.node === node ? '' : node; rerenderMemory(); }
2555
- function renderMemory(memory) {
2663
+ function memorySyncLine(sync) {
2664
+ if (!sync || typeof sync.state !== 'string') return 'Machine sync: unknown';
2665
+ const hasTimestamp = sync.at !== undefined, validTimestamp = Number.isSafeInteger(sync.at) && Math.abs(sync.at) <= 8640000000000000;
2666
+ const stamp = hasTimestamp ? ' / last ' + (validTimestamp ? time(sync.at) : 'unknown') : '';
2667
+ if (sync.state === 'not-configured' || sync.state === 'stopped') {
2668
+ const next = sync.state === 'stopped' ? 'retry after hub restart' : 'configure a private sync remote';
2669
+ return 'Machine sync: ' + sync.state + stamp + ' / next: ' + next;
2670
+ }
2671
+ if (sync.state === 'ok' && Number.isSafeInteger(sync.withheldCount) && sync.withheldCount >= 0 && Number.isSafeInteger(sync.exportedCount) && sync.exportedCount >= 0)
2672
+ return 'Machine sync: ok' + stamp + ' / ' + sync.withheldCount + ' row' + (sync.withheldCount === 1 ? '' : 's') + ' withheld locally / ' + sync.exportedCount + ' exported';
2673
+ if (sync.state !== 'failed') return 'Machine sync: ' + (['disabled','unavailable','notrun','ok','unknown'].includes(sync.state) ? sync.state : 'unknown') + stamp;
2674
+ const causes = ['declined','remote','binding','stopped','transport','unknown'];
2675
+ const cause = causes.includes(sync.cause) ? sync.cause : 'unknown';
2676
+ if (cause === 'declined' && sync.placementActive === false) {
2677
+ const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? String(sync.declinedCount) : 'unknown';
2678
+ return 'machine sync blocked: ' + declined + ' declined rows; per-row placement is not active on this store (agent-mem has no activation command yet, agent-mem#281). Inspect with `ahub memory declined`.';
2679
+ }
2680
+ const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? ' / ' + sync.declinedCount + ' declined' : '';
2681
+ const ids = cause === 'declined' && sync.declinedIdsKnown === false ? ' / declined ID set unknown' : '';
2682
+ const next = cause === 'declined' ? 'ahub memory declined' : cause === 'remote' ? 'configure a private sync remote' : cause === 'binding' ? 'ahub memory profile-check' : cause === 'stopped' ? 'retry after hub restart' : 'run ahub memory sync as a person';
2683
+ return 'Machine sync: failed (' + cause + count + ')' + stamp + ids + ' / next: ' + next;
2684
+ }
2685
+ function renderMemory(memory, sync) {
2686
+ $('memory-sync-status').textContent = memorySyncLine(sync);
2556
2687
  memoryCodeToggle();
2557
2688
  const tiers = $('memory-tiers');
2558
2689
  if (!tiers.children.length) for (const [tier, label] of [['short', 'Short-term'], ['long', 'Long-term'], ['goals', 'Long-term tasks']]) {
package/src/ui/needs.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type NeedKind = "approval" | "default" | "storage" | "recovery" | "delivery" | "review" | "message" | "budget" | "offline" | "stall" | "offer";
1
+ export type NeedKind = "approval" | "default" | "storage" | "recovery" | "delivery" | "review" | "message" | "budget" | "offline" | "stall" | "offer" | "question" | "result";
2
2
  export interface NeedItem { kind: NeedKind; level: "act" | "watch"; peer?: string; id?: string | number; until?: number; since?: number; }
3
3
  export interface NeedsInput {
4
4
  approvals?: readonly { id: string; peer?: string; expiresAt: number; since?: number }[];
@@ -10,6 +10,10 @@ export interface NeedsInput {
10
10
  facts?: readonly { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
11
11
  messages?: readonly { id?: string; peer?: string; important: boolean; since?: number }[];
12
12
  budget?: Readonly<Record<string, { paused?: { since?: number; resetsAt?: number } }>>;
13
+ /** #1053: the open questions a peer asked the person: the set id, the asking peer and when it was asked, never a prompt or an option. */
14
+ questions?: readonly { set: string; peer?: string; since?: number }[];
15
+ /** #1054: unread results, a run a person started that settled and has not been seen: the completion id, the peer and when it settled, never a prompt or a reply. */
16
+ results?: readonly { id: string; peer?: string; since?: number }[];
13
17
  }
14
18
  export interface Needs { items: NeedItem[]; act: number; watch: number; state: "waiting" | "blocked" | "review" | "working" | "idle" | "sleeping"; }
15
19
  export interface NeedsSummary { act: number; watch: number; state: Needs["state"]; first?: NeedItem }
@@ -22,6 +26,7 @@ export function needs(input: NeedsInput, now: number): Needs {
22
26
  const add = (kind: NeedKind, level: "act" | "watch", peer?: unknown, identifier?: unknown, until?: unknown, since?: unknown) => items.push({ kind, level, ...(id(peer) ? { peer } : {}), ...(id(identifier) || typeof identifier === "number" && Number.isSafeInteger(identifier) && identifier > 0 ? { id: identifier } : {}), ...(time(until) ? { until } : {}), ...(time(since) ? { since } : {}) });
23
27
  for (const request of input.approvals ?? []) if (id(request.id) && time(request.expiresAt) && request.expiresAt > now) add("approval", "act", request.peer, request.id, request.expiresAt, request.since);
24
28
  for (const pending of input.defaults ?? []) if (id(pending.peer)) add("default", "act", pending.peer);
29
+ for (const question of input.questions ?? []) if (id(question.set)) add("question", "act", question.peer, question.set, undefined, question.since);
25
30
  if (input.storageBlocked === true) add("storage", "act");
26
31
  if (input.recovery) add("recovery", input.recovery.running === false ? "act" : "watch", undefined, input.recovery.id, undefined, input.recovery.since);
27
32
  for (const [peer, status] of Object.entries(input.peers ?? {})) {
@@ -44,17 +49,18 @@ export function needs(input: NeedsInput, now: number): Needs {
44
49
  else if (fact.kind === "offline" && agent(fact.peer) && !offline.has(fact.peer)) { offline.add(fact.peer); add("offline", "watch", fact.peer, fact.id); }
45
50
  }
46
51
  for (const [peer, budget] of Object.entries(input.budget ?? {})) if (budget.paused) add("budget", "watch", peer, undefined, budget.paused.resetsAt, budget.paused.since);
47
- const order: Record<NeedKind, number> = { approval: 0, default: 1, storage: 2, recovery: 3, delivery: 4, review: 5, stall: 6, offer: 7, message: 8, budget: 9, offline: 10 };
52
+ for (const result of input.results ?? []) if (id(result.id)) add("result", "watch", result.peer, result.id, undefined, result.since);
53
+ const order: Record<NeedKind, number> = { approval: 0, default: 1, question: 2, storage: 3, recovery: 4, delivery: 5, review: 6, stall: 7, offer: 8, message: 9, result: 10, budget: 11, offline: 12 };
48
54
  items.sort((a,b) => (a.level === "act" ? 0 : 1) - (b.level === "act" ? 0 : 1) || order[a.kind] - order[b.kind] || (a.kind === "approval" ? (a.until ?? Infinity) - (b.until ?? Infinity) : 0) || (a.since ?? 0) - (b.since ?? 0));
49
55
  const act = items.filter(item => item.level === "act").length, watch = items.length - act;
50
56
  const peers = Object.values(input.peers ?? {}), attached = peers.some(peer => peer.attached !== false && ["idle", "busy", "paused"].includes(peer.state ?? ""));
51
- const state: Needs["state"] = items.some(item => item.kind === "approval" || item.kind === "default") ? "waiting" : items.some(item => item.level === "act" && ["storage", "delivery", "recovery", "stall"].includes(item.kind)) ? "blocked" : act ? "review" : peers.some(peer => peer.attached !== false && peer.state === "busy") ? "working" : attached ? "idle" : "sleeping";
57
+ const state: Needs["state"] = items.some(item => item.kind === "approval" || item.kind === "default" || item.kind === "question") ? "waiting" : items.some(item => item.level === "act" && ["storage", "delivery", "recovery", "stall"].includes(item.kind)) ? "blocked" : act ? "review" : peers.some(peer => peer.attached !== false && peer.state === "busy") ? "working" : attached ? "idle" : "sleeping";
52
58
  return { items, act, watch, state };
53
59
  }
54
60
 
55
61
  /** Global status never reads console-local unread messages or source text. */
56
62
  export function summarizeNeeds(input: Omit<NeedsInput, "messages">, now: number): NeedsSummary {
57
63
  const value = needs({ approvals: input.approvals, defaults: input.defaults, peers: input.peers,
58
- storageBlocked: input.storageBlocked, recovery: input.recovery, tasks: input.tasks, facts: input.facts, budget: input.budget }, now);
64
+ storageBlocked: input.storageBlocked, recovery: input.recovery, tasks: input.tasks, facts: input.facts, budget: input.budget, questions: input.questions, results: input.results }, now);
59
65
  return { act: value.act, watch: value.watch, state: value.state, ...(value.items[0] ? { first: value.items[0] } : {}) };
60
66
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * #1053 T5: the acon inbox side of a question a peer asked with hub_ask. Pure: it builds the inbox item from the stored row the hub reports and checks the
3
+ * person evidence an answer needs; `plugin.ts` owns the transport. No text here is logged.
4
+ *
5
+ * Authority: an answer is accepted only when the console advertises `action.meta` and `action()` received the console's own `meta` (`via: "console"`, a verified
6
+ * `identity`), which the page cannot set (agent-console `action(input, meta?)`). An answer without it is refused before any request reaches the hub.
7
+ */
8
+
9
+ import type { ActionMeta, InboxAnswers, InboxVerb, VerbForm } from "@staix/agent-console/contract";
10
+
11
+ // The acon types come only through the generated bridge (`scripts/acon-review-types.ts`, regenerated and compared by its check), so a drift of the form or of the answer it
12
+ // submits fails there and not at a user's console. Type-only: agent-hub keeps zero runtime dependencies.
13
+ export type { ActionMeta, InboxAnswers };
14
+ export interface QuestionRowView {
15
+ set: string; peer: string; created: number; private?: boolean;
16
+ questions: { id: string; prompt: string; options: { id: string; label: string; description?: string }[]; multi?: boolean; allowOther?: boolean }[];
17
+ }
18
+ type FormQuestion = VerbForm["questions"][number];
19
+ export interface QuestionItem {
20
+ id: string; group: "decision"; kind: string; title: string; actor: string; project: string; at: string; chip: { text: string; tone: "warn" };
21
+ verbs: InboxVerb[];
22
+ link: { panel: string; params: Record<string, string> };
23
+ }
24
+
25
+ /** Both features are needed: `inbox.form` renders the form, `action.meta` gives the answer its person evidence. One alone is the plain item. */
26
+ export const canAnswerInInbox = (features: readonly string[] | undefined): boolean => !!features?.includes("inbox.form") && features.includes("action.meta");
27
+
28
+ const flat = (text: string, max: number) => Array.from(text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]+/g, " ").trim()).slice(0, max).join("");
29
+ export const questionItemId = (project: string, set: string) => `question:${project}:${set}`;
30
+ export const QUESTION_ITEM = /^question:([^:]+):(qs-[a-f0-9]{12})$/;
31
+
32
+ /**
33
+ * The inbox item. A private question (asked in a PII turn or about a PII task) gets a stub title and no detail: its text is in the form only, which is the person's
34
+ * own answer surface. Without the two features the item is a plain decision with an `open` verb that links to the agents panel.
35
+ */
36
+ export function questionItem(row: QuestionRowView, project: { id: string; name: string }, features: readonly string[] | undefined): QuestionItem {
37
+ const head = row.private ? "a question" : `: ${flat(row.questions[0]?.prompt ?? "", 80)}`;
38
+ const title = (row.private ? `${row.peer} asks ${head}` : `${row.peer} asks${head}`).slice(0, 200);
39
+ const link = { panel: "agents", params: { project: project.id } };
40
+ const base = { id: questionItemId(project.id, row.set), group: "decision" as const, title, actor: row.peer, project: project.name, at: new Date(row.created).toISOString(), chip: { text: "question", tone: "warn" as const }, link };
41
+ if (!canAnswerInInbox(features)) return { ...base, kind: "decision", verbs: [{ id: "open", label: "Open Agents", primary: true }] };
42
+ const form = { questions: row.questions.map<FormQuestion>(question => ({ id: question.id, prompt: question.prompt, options: question.options.map(option => ({ id: option.id, label: option.label, ...(option.description ? { description: option.description } : {}) })),
43
+ ...(question.multi === true ? { multiple: true } : {}), ...(question.allowOther === true ? { other: { maxLength: 500 } } : {}) })) };
44
+ return { ...base, kind: "question", verbs: [{ id: "answer", label: "Answer", primary: true, form }, { id: "open", label: "Open Agents" }] };
45
+ }
46
+
47
+ /** The console's evidence, or the refusal that tells the person how to get it. Page-supplied fields that would stand in for it are refused too. */
48
+ export function personEvidence(features: readonly string[] | undefined, meta: ActionMeta | undefined, input: Record<string, unknown>): { ok: true; identity: string } | { ok: false; error: string } {
49
+ if (!features?.includes("action.meta") || !meta || typeof meta !== "object") return { ok: false, error: "Answering needs a console with verified person identity; update acon and reopen this panel, or answer with ahub answer." };
50
+ const value = meta as unknown as Record<string, unknown>;
51
+ if (value.via !== "console" || typeof value.identity !== "string" || !value.identity.trim() || typeof value.node !== "string" || !value.node.trim())
52
+ return { ok: false, error: "Answering needs a console with verified person identity; update acon and reopen this panel, or answer with ahub answer." };
53
+ if (["author", "meta", "personMeta", "person", "identity", "surface", "questionSet", "projectId", "instanceId"].some(key => Object.hasOwn(input, key)))
54
+ return { ok: false, error: "The person's identity and the target come from the console; remove those fields and retry." };
55
+ return { ok: true, identity: value.identity.trim().slice(0, 200) };
56
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * #1054 T6: the acon inbox side of a result: a run a person started that settled and has not been seen. Pure: it builds the inbox item from the row the hub reports and checks the
3
+ * person evidence a receipt needs; `plugin.ts` owns the transport. No text here is logged.
4
+ *
5
+ * Publication: an item is published only to a console that advertises BOTH `inbox.result` (it renders the group) and `action.meta` (a receipt then has person evidence). One alone,
6
+ * or neither, and results stay in the ahub panel, `ahub console`, `ahub status` and `ahub results`: nothing is published that the console could not render or that the person
7
+ * could not mark read with evidence.
8
+ *
9
+ * Authority: `result_seen`, `results_read_all` and `results_unread_last` are accepted only when the console advertises `action.meta` and `action()` received the console's own
10
+ * `meta` (`via: "console"`, a verified `identity`), which the page cannot set (agent-console `action(input, meta?)`). They never go through the dashboard's forwarding list. The
11
+ * acon "seeing" rule (a focused, visible window, the result's end in view for a second, the latest edge) is agent-console#27 and is not decided here: this only accepts the receipt.
12
+ * Mark unread is NOT an inbox verb: a read item leaves the inbox, so mark-unread lives in the ahub panel (this plugin's dashboard) and in `ahub results --unread-last`.
13
+ */
14
+
15
+ import type { ActionMeta } from "@staix/agent-console/contract";
16
+
17
+ export type { ActionMeta };
18
+ export type ResultOutcome = "done" | "failed" | "limit" | "interrupted";
19
+ /** The detail the hub built at display (`completion-detail.ts`): a head of the request, a stub, or nothing. */
20
+ export type ResultDetailView = { kind: "head" | "stub"; text: string } | { kind: "none" };
21
+ /** One row of the hub's `results` snapshot field. */
22
+ export interface ResultRowView {
23
+ id: string; peer: string; outcome: ResultOutcome; settledAt: number; ms?: number; private?: boolean; title?: string; detail?: ResultDetailView;
24
+ }
25
+ export interface ResultItem {
26
+ id: string; group: "result"; kind: "result"; title: string; detail?: string; actor: string; project: string; at: string; chip: { text: string; tone: "ok" | "warn" | "err" | "neutral" };
27
+ verbs: { id: string; label: string; primary?: boolean }[];
28
+ link: { panel: string; params: Record<string, string> };
29
+ seen: { action: "result_seen" };
30
+ }
31
+
32
+ /**
33
+ * The receipt actions this plugin advertises (so acon lets the page and the inbox send them), and answers itself with the console's person meta: never in the dashboard's forwarding
34
+ * list. `result_seen` and `results_read_all` come from the inbox (acon's seeing rule and its "Mark all read") and from the panel; `results_unread_last` only from the panel, because a
35
+ * read item has left the inbox: there is no inbox verb for it. All three are the person's writes, so all three need the same evidence.
36
+ */
37
+ export const RESULT_ACTIONS = ["result_seen", "results_read_all", "results_unread_last"] as const;
38
+ export type ResultAction = (typeof RESULT_ACTIONS)[number];
39
+
40
+ /** Both features are needed. One alone is not enough: no group to render, or no person evidence for the receipt. */
41
+ export const canPublishResults = (features: readonly string[] | undefined): boolean => !!features?.includes("inbox.result") && features.includes("action.meta");
42
+
43
+ export const resultItemId = (project: string, id: string) => `result:${project}:${id}`;
44
+ export const RESULT_ITEM = /^result:([^:]+):(cmp-[a-f0-9]{12})$/;
45
+ const OUTCOME_WORD: Record<ResultOutcome, string> = { done: "finished", failed: "failed", limit: "stopped at a limit", interrupted: "interrupted" };
46
+ const CHIP: Record<ResultOutcome, { text: string; tone: "ok" | "warn" | "err" | "neutral" }> = {
47
+ done: { text: "done", tone: "ok" }, failed: { text: "failed", tone: "err" }, limit: { text: "limit", tone: "warn" }, interrupted: { text: "interrupted", tone: "warn" },
48
+ };
49
+ const flat = (text: string, max: number) => Array.from(text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]+/g, " ").trim()).slice(0, max).join("");
50
+
51
+ /**
52
+ * The inbox item. The title is `<peer>: <outcome word>`, computed here from the row's peer and outcome and never taken from the row's own title text; a private result is a stub
53
+ * (`<peer>: a private result`) with no detail. The detail is whatever the hub built at display, or none: this never reads a prompt itself. `at` is the settle time, stable
54
+ * across snapshots, which is what acon's snooze and oldest-first order key on.
55
+ */
56
+ export function resultItem(row: ResultRowView, project: { id: string; name: string }): ResultItem {
57
+ const outcome: ResultOutcome = row.outcome in OUTCOME_WORD ? row.outcome : "interrupted";
58
+ const title = (row.private ? `${row.peer}: a private result` : `${row.peer}: ${OUTCOME_WORD[outcome]}`).slice(0, 200);
59
+ const detail = !row.private && row.detail && row.detail.kind !== "none" && typeof row.detail.text === "string" ? flat(row.detail.text, 200) : row.private ? "a private request" : "";
60
+ return {
61
+ id: resultItemId(project.id, row.id), group: "result", kind: "result", title, ...(detail ? { detail } : {}), actor: row.peer, project: project.name,
62
+ at: new Date(row.settledAt).toISOString(), chip: CHIP[outcome], verbs: [{ id: "open", label: "Open Agents", primary: true }],
63
+ link: { panel: "agents", params: { project: project.id } }, seen: { action: "result_seen" },
64
+ };
65
+ }
66
+
67
+ const REFUSAL = "Marking a result read needs a console with verified person identity; update acon and reopen this panel, or use ahub results.";
68
+ /**
69
+ * The console's evidence, or the refusal that tells the person how to get it. Page-supplied fields that would stand in for the person (`author`, `meta`, `identity`, `surface`) or
70
+ * name a completion by another key are refused too. `projectId` is allowed: the panel names the project it is showing, as every dashboard action does, and it only picks WHICH running
71
+ * project's receipt this is; the daemon instance is never taken from the page (`receiptVerb` resolves it from the running listing), and the person evidence is the console's alone.
72
+ */
73
+ export function receiptEvidence(features: readonly string[] | undefined, meta: ActionMeta | undefined, input: Record<string, unknown>): { ok: true } | { ok: false; error: string } {
74
+ if (!features?.includes("action.meta") || !meta || typeof meta !== "object") return { ok: false, error: REFUSAL };
75
+ const value = meta as unknown as Record<string, unknown>;
76
+ if (value.via !== "console" || typeof value.identity !== "string" || !value.identity.trim() || typeof value.node !== "string" || !value.node.trim()) return { ok: false, error: REFUSAL };
77
+ // `seenAt` and `explicit` are acon's own (the shell sends them with the seeing rule): the hub stamps its own time, so they are accepted and ignored, never trusted.
78
+ if (["author", "meta", "personMeta", "person", "identity", "surface", "completion", "completionId", "seen"].some(key => Object.hasOwn(input, key)))
79
+ return { ok: false, error: "The person's identity and the target come from the console; remove those fields and retry." };
80
+ return { ok: true };
81
+ }