@staix/agent-hub 0.30.0 → 0.32.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.
- package/CHANGELOG.md +52 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +11 -4
- package/docs/agent-notes/daemon.md +16 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +178 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +23 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +355 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +24 -5
- package/docs/security.md +178 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-0.32.0.md +149 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +78 -17
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/room.ts +20 -10
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +511 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1576 -139
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/commands.ts +61 -16
- package/src/rooms/link.ts +338 -36
- package/src/rooms/liveness.ts +11 -0
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +475 -35
- package/src/rooms/status.ts +8 -1
- package/src/rooms/store.ts +447 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +142 -9
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- 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
|
|
111
|
-
|
|
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 & 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
|
-
|
|
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;
|
|
@@ -1599,8 +1674,10 @@ function renderRooms(rooms) {
|
|
|
1599
1674
|
const peers = el('div', undefined, 'peer-summary');
|
|
1600
1675
|
for (const peer of room.remotePeers) {
|
|
1601
1676
|
const id = roomMetadataText(peer.id, /^[a-z][a-z0-9-]{0,31}@[a-z][a-z0-9-]{0,31}$/, 65);
|
|
1602
|
-
const
|
|
1603
|
-
|
|
1677
|
+
const observed = peer.presence ?? peer.state;
|
|
1678
|
+
const state = ['idle', 'busy', 'paused', 'offline', 'unknown'].includes(observed) ? observed : 'unknown';
|
|
1679
|
+
const note = peer.liveness === 'untracked' ? '; liveness not tracked (broker without ephemeral); upgrade the broker to track liveness' : '';
|
|
1680
|
+
peers.append(el('span', id + ': ' + state + note, 'meta'));
|
|
1604
1681
|
}
|
|
1605
1682
|
row.append(peers);
|
|
1606
1683
|
}
|
|
@@ -1855,8 +1932,9 @@ function render(snapshot) {
|
|
|
1855
1932
|
}
|
|
1856
1933
|
});
|
|
1857
1934
|
renderBench(snapshot.bench);
|
|
1935
|
+
renderResults(snapshot.results);
|
|
1858
1936
|
renderWorktrees(snapshot.worktrees);
|
|
1859
|
-
renderMemory(snapshot.memory);
|
|
1937
|
+
renderMemory(snapshot.memory, snapshot.memorySync);
|
|
1860
1938
|
renderSettings(snapshot.settings, snapshot.roster);
|
|
1861
1939
|
renderUsage(snapshot.usage, snapshot.tasks, snapshot.budget);
|
|
1862
1940
|
renderSchedules(snapshot.schedules);
|
|
@@ -1888,6 +1966,9 @@ async function poll() {
|
|
|
1888
1966
|
const input = {after:cursor, memory:memoryInput(), view:currentView}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
|
|
1889
1967
|
const result = await post('/snapshot', input);
|
|
1890
1968
|
if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
|
|
1969
|
+
const detailTask = taskDetailId === null ? undefined : result.tasks?.find(task => task.id === taskDetailId), detailKey = reviewCommentsCacheKey(detailTask, captured);
|
|
1970
|
+
if (taskDetailId !== null && reviewCommentsCache.has(detailKey)) result.reviewComments = reviewCommentsCache.get(detailKey);
|
|
1971
|
+
else if (taskDetailId !== null) reviewCommentsCache.clear();
|
|
1891
1972
|
snapshotValid = true; lastSnapshot = result; lastSnapshotAt = Date.now(); render(result); setConnection('Live / updated ' + new Date().toLocaleTimeString(), 'live'); setDisconnected(false);
|
|
1892
1973
|
}
|
|
1893
1974
|
}
|
|
@@ -2371,6 +2452,35 @@ let streamFilter = '';
|
|
|
2371
2452
|
function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
|
|
2372
2453
|
function refreshStreamEmpty() { const has = $('stream').querySelector('.event:not([hidden])') !== null, any = $('stream').querySelector('.event') !== null; $('stream-empty').hidden = has || !any; }
|
|
2373
2454
|
// #251: benchmark runs on this machine and arm comparisons per suite. Every bar carries its number as text.
|
|
2455
|
+
/* #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
|
|
2456
|
+
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
|
|
2457
|
+
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
|
|
2458
|
+
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. */
|
|
2459
|
+
function renderResults(results) {
|
|
2460
|
+
const unread = results && Array.isArray(results.unread) ? results.unread : [];
|
|
2461
|
+
const more = results && Number.isSafeInteger(results.moreUnread) ? results.moreUnread : 0;
|
|
2462
|
+
$('results-count').textContent = results ? unread.length + more + ' unread' : '';
|
|
2463
|
+
update('results', results === undefined ? null : results, (target) => {
|
|
2464
|
+
if (results === undefined) return empty(target, 'Results are shown to the person on a current snapshot.');
|
|
2465
|
+
const tools = el('div', undefined, 'row-head');
|
|
2466
|
+
if (unread.length) tools.append(button('Mark all read', {action: 'results_read_all'}));
|
|
2467
|
+
if (results.canUnread) tools.append(button('Mark last unread again', {action: 'results_unread_last'}));
|
|
2468
|
+
if (!unread.length) target.append(el('p', 'Nothing unread.', 'meta'));
|
|
2469
|
+
for (const item of unread) {
|
|
2470
|
+
const block = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
|
|
2471
|
+
head.append(el('span', String(item.title), 'label'));
|
|
2472
|
+
head.append(badge(String(item.outcome), item.outcome === 'done' ? 'ok' : item.outcome === 'failed' ? 'err' : 'warn'));
|
|
2473
|
+
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);
|
|
2474
|
+
if (Number.isFinite(item.ms)) head.append(el('span', Math.round(item.ms / 1000) + 's', 'meta'));
|
|
2475
|
+
head.append(button('Mark read', {action: 'result_seen', id: String(item.id)}));
|
|
2476
|
+
block.append(head);
|
|
2477
|
+
if (item.detail && (item.detail.kind === 'head' || item.detail.kind === 'stub') && typeof item.detail.text === 'string') block.append(el('p', item.detail.text, 'meta'));
|
|
2478
|
+
target.append(block);
|
|
2479
|
+
}
|
|
2480
|
+
if (more > 0) target.append(el('p', more + ' older unread; run ahub results --unread to list them all.', 'meta'));
|
|
2481
|
+
target.append(tools);
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2374
2484
|
/* #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
2485
|
function renderWorktrees(shelf) {
|
|
2376
2486
|
const live = shelf && Array.isArray(shelf.live) ? shelf.live : [], settled = shelf && Array.isArray(shelf.settled) ? shelf.settled : [];
|
|
@@ -2552,7 +2662,30 @@ function layout(graph) {
|
|
|
2552
2662
|
return pos;
|
|
2553
2663
|
}
|
|
2554
2664
|
function selectMemoryNode(node) { memoryQuery.node = memoryQuery.node === node ? '' : node; rerenderMemory(); }
|
|
2555
|
-
function
|
|
2665
|
+
function memorySyncLine(sync) {
|
|
2666
|
+
if (!sync || typeof sync.state !== 'string') return 'Machine sync: unknown';
|
|
2667
|
+
const hasTimestamp = sync.at !== undefined, validTimestamp = Number.isSafeInteger(sync.at) && Math.abs(sync.at) <= 8640000000000000;
|
|
2668
|
+
const stamp = hasTimestamp ? ' / last ' + (validTimestamp ? time(sync.at) : 'unknown') : '';
|
|
2669
|
+
if (sync.state === 'not-configured' || sync.state === 'stopped') {
|
|
2670
|
+
const next = sync.state === 'stopped' ? 'retry after hub restart' : 'configure a private sync remote';
|
|
2671
|
+
return 'Machine sync: ' + sync.state + stamp + ' / next: ' + next;
|
|
2672
|
+
}
|
|
2673
|
+
if (sync.state === 'ok' && Number.isSafeInteger(sync.withheldCount) && sync.withheldCount >= 0 && Number.isSafeInteger(sync.exportedCount) && sync.exportedCount >= 0)
|
|
2674
|
+
return 'Machine sync: ok' + stamp + ' / ' + sync.withheldCount + ' row' + (sync.withheldCount === 1 ? '' : 's') + ' withheld locally / ' + sync.exportedCount + ' exported';
|
|
2675
|
+
if (sync.state !== 'failed') return 'Machine sync: ' + (['disabled','unavailable','notrun','ok','unknown'].includes(sync.state) ? sync.state : 'unknown') + stamp;
|
|
2676
|
+
const causes = ['declined','remote','binding','stopped','transport','unknown'];
|
|
2677
|
+
const cause = causes.includes(sync.cause) ? sync.cause : 'unknown';
|
|
2678
|
+
if (cause === 'declined' && sync.placementActive === false) {
|
|
2679
|
+
const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? String(sync.declinedCount) : 'unknown';
|
|
2680
|
+
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`.';
|
|
2681
|
+
}
|
|
2682
|
+
const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? ' / ' + sync.declinedCount + ' declined' : '';
|
|
2683
|
+
const ids = cause === 'declined' && sync.declinedIdsKnown === false ? ' / declined ID set unknown' : '';
|
|
2684
|
+
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';
|
|
2685
|
+
return 'Machine sync: failed (' + cause + count + ')' + stamp + ids + ' / next: ' + next;
|
|
2686
|
+
}
|
|
2687
|
+
function renderMemory(memory, sync) {
|
|
2688
|
+
$('memory-sync-status').textContent = memorySyncLine(sync);
|
|
2556
2689
|
memoryCodeToggle();
|
|
2557
2690
|
const tiers = $('memory-tiers');
|
|
2558
2691
|
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
|
|
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
|
+
}
|