@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.3
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/README.md +55 -0
- package/package.json +7 -4
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +63 -0
- package/src/commands/pulse/doctor.js +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +20 -12
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +411 -0
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +140 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +57 -6
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +64 -1
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +149 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// agents-hub lib/collect.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Builds everything the hub shows from what sessions leave on disk: Cursor transcripts (who exists, who started whom,
|
|
3
|
+
// what each agent was asked), task channels (what happened, who has the ball) and the hub's own requests and events.
|
|
4
|
+
// Tokens are an estimate (transcript bytes / 4): transcripts hold no usage, tool output or system prompts.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { managerChats } from '../core/ids.mjs';
|
|
8
|
+
import { channelFiles, firstHeading, holds, parsePosts } from './channels.mjs';
|
|
9
|
+
import { hasChatInbox, liveOf } from './chat.mjs';
|
|
10
|
+
import { chatTitles } from './titles.mjs';
|
|
11
|
+
import { buildAgents, buildDeliveries, streamView } from './deliveries.mjs';
|
|
12
|
+
import { gitInfo, plannedCheckout, taskCheckout } from './git.mjs';
|
|
13
|
+
import { personaFor, savePersonas } from './personas.mjs';
|
|
14
|
+
import { HUB, readAccepted, readRequests } from './store.mjs';
|
|
15
|
+
import { channelsOwned, pruneOwnCache, scanSessions, scanTranscripts, sessionTail, topSession } from './transcripts.mjs';
|
|
16
|
+
|
|
17
|
+
const WORK = path.resolve(HUB, '..');
|
|
18
|
+
const CHANNEL_ROOTS = ['AgentPulseApps', 'Google_Cloud'].map((d) => path.join(WORK, d));
|
|
19
|
+
const ACTIVE_MS = 10 * 60e3;
|
|
20
|
+
const IDLE_MS = 60 * 60e3;
|
|
21
|
+
const ORPHAN_LOOKBACK_MS = 48 * 3600e3;
|
|
22
|
+
/** A subagent file this small is the launch stub only; its real conversation was not saved, so its tokens are unknown. */
|
|
23
|
+
const STUB_BYTES = 8 * 1024;
|
|
24
|
+
const MANAGER = 'manager';
|
|
25
|
+
/** Ids of the requests collect derives from channel posts. Other request facts (hub.mjs request, imports) are not its. */
|
|
26
|
+
export const DERIVED_REQUEST_PREFIX = 'd-';
|
|
27
|
+
const REQUEST_HINT = /\bPROMOTE\b|platform gap|needs? (?:a )?platform change|common script/i;
|
|
28
|
+
|
|
29
|
+
/** The hub's Cursor hook fires on every tool call, so a chat it heard from this recently is running and gets messages at its next step. */
|
|
30
|
+
const LIVE_MS = 3 * 60e3;
|
|
31
|
+
|
|
32
|
+
function withLive(row, now) {
|
|
33
|
+
const chatInbox = hasChatInbox(row.id);
|
|
34
|
+
const live = chatInbox ? liveOf(row.id) : null;
|
|
35
|
+
row.reach = { ...row.reach, chatInbox, live };
|
|
36
|
+
if (!live || row.reach.state === 'finished' || live.at < row.reach.lastAt - LIVE_MS) return;
|
|
37
|
+
if (live.event === 'stop') row.reach.state = 'turn-ended';
|
|
38
|
+
else if (now - live.at < LIVE_MS) row.reach.state = 'connected';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const statusOf = (lastAt, now) => (now - lastAt < ACTIVE_MS ? 'active' : now - lastAt < IDLE_MS ? 'idle' : 'done');
|
|
42
|
+
/**
|
|
43
|
+
* What header totals are counted from, so a window can filter one canonical pass (projections/graph.mjs). One entry per
|
|
44
|
+
* person, [kind 's' | 'a' | 'o', seenAt, lastAt, tokensEst], where seenAt is the later of lastAt and `seenAt`: a role
|
|
45
|
+
* that posted inside a window, holds the ball of a stream touched in it, or runs under the hub is in it even when its
|
|
46
|
+
* transcript is older. Message events collapse to [at, count].
|
|
47
|
+
*/
|
|
48
|
+
export function totalsBasis(people, events, seenAt = new Map()) {
|
|
49
|
+
const counts = new Map();
|
|
50
|
+
for (const e of events) counts.set(e.at, (counts.get(e.at) || 0) + 1);
|
|
51
|
+
const kind = (n) => (n.kind === 'session' ? 's' : n.kind === 'agent' ? 'a' : 'o');
|
|
52
|
+
return {
|
|
53
|
+
people: people.map((n) => [kind(n), Math.max(n.lastAt || 0, seenAt.get(n.id) || 0), n.lastAt || 0, n.tokensEst || 0]),
|
|
54
|
+
messages: [...counts].sort((a, b) => a[0] - b[0]),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const titleCase = (s) => s.toLowerCase().replace(/(^|\s)\w/g, (c) => c.toUpperCase());
|
|
59
|
+
|
|
60
|
+
function recipients(role, roles) {
|
|
61
|
+
const others = roles.filter((r) => r !== role);
|
|
62
|
+
if (role.includes('CODER')) return others.filter((r) => r.includes('REVIEWER') || r.includes('AUDITOR'));
|
|
63
|
+
if (role.includes('REVIEWER') || role.includes('AUDITOR')) return others.filter((r) => r.includes('CODER'));
|
|
64
|
+
return others.filter((r) => !r.includes('AUDITOR'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Each stream belongs to one session: the parent of its agents, else the most recent session (up to 48 h back) whose
|
|
69
|
+
* briefs or writes name it. Sessions found that way join the node list, since they are the streams' architects.
|
|
70
|
+
*/
|
|
71
|
+
function assignOwners(nodes, channels, index, now) {
|
|
72
|
+
const owner = new Map();
|
|
73
|
+
for (const c of channels) {
|
|
74
|
+
const parented = [...nodes.values()].find((n) => n.channelKey === c.key && n.parent && nodes.get(n.parent) && nodes.get(n.parent).kind === 'session');
|
|
75
|
+
if (parented) owner.set(c.key, parented.parent);
|
|
76
|
+
}
|
|
77
|
+
const missing = new Set(channels.filter((c) => !owner.has(c.key)).map((c) => c.key));
|
|
78
|
+
if (missing.size) {
|
|
79
|
+
for (const s of scanSessions(now - ORPHAN_LOOKBACK_MS, index).sort((a, b) => b.lastAt - a.lastAt)) {
|
|
80
|
+
if (!missing.size) break;
|
|
81
|
+
const top = topSession(s.id, index) || s;
|
|
82
|
+
for (const k of channelsOwned(s.file, index)) {
|
|
83
|
+
if (!missing.has(k)) continue;
|
|
84
|
+
missing.delete(k);
|
|
85
|
+
owner.set(k, top.id);
|
|
86
|
+
if (!nodes.has(top.id)) nodes.set(top.id, top);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
pruneOwnCache(now - ORPHAN_LOOKBACK_MS);
|
|
91
|
+
for (const n of nodes.values()) if (n.kind === 'agent' && !n.parent && n.channelKey && owner.has(n.channelKey) && owner.get(n.channelKey) !== n.id) n.parent = owner.get(n.channelKey);
|
|
92
|
+
return owner;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whoever holds a stream's ball must be on the map even if it has not posted inside the time window (a coder sitting
|
|
97
|
+
* on a failed review is exactly what you need to see): add it from its last post in the channel.
|
|
98
|
+
*/
|
|
99
|
+
function addBallHolders(nodes, streams, owner, now) {
|
|
100
|
+
for (const s of streams.values()) {
|
|
101
|
+
if (s.done || !s.ball || s.ball === 'YOU' || s.ball === 'ARCHITECT') continue;
|
|
102
|
+
if ([...nodes.values()].some((n) => n.channelKey === s.key && n.role && holds(n.role, s.ball))) continue;
|
|
103
|
+
const [role, post] = Object.entries(s.lastByRole).filter(([r]) => holds(r, s.ball)).sort((a, b) => b[1].at - a[1].at)[0] || [];
|
|
104
|
+
if (!role) continue;
|
|
105
|
+
const id = `${s.key}:${role}`;
|
|
106
|
+
nodes.set(id, { id, kind: 'agent', role, task: s.channel, channel: s.channel, channelKey: s.key, repo: s.repo, label: titleCase(role),
|
|
107
|
+
title: `${role} on ${s.channel} (seen only in its channel)`, parent: owner.get(s.key) || null, startedAt: post.at, lastAt: post.at, bytes: 0, tokensEst: null, status: statusOf(post.at, now), synthetic: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* When each stream's ball holder was last on the map. A window shows the holder whenever the stream's channel changed
|
|
113
|
+
* inside it (addBallHolders), so in the canonical pass the holder counts as seen at the channel's last change.
|
|
114
|
+
*/
|
|
115
|
+
function ballHeldAt(nodes, streams, channels) {
|
|
116
|
+
const changed = new Map(channels.map((c) => [c.key, c.mtime]));
|
|
117
|
+
const held = new Map();
|
|
118
|
+
for (const s of streams.values()) {
|
|
119
|
+
if (s.done || !s.ball || s.ball === 'YOU' || s.ball === 'ARCHITECT') continue;
|
|
120
|
+
if (!Object.keys(s.lastByRole).some((r) => holds(r, s.ball))) continue;
|
|
121
|
+
let holder = null;
|
|
122
|
+
for (const n of nodes.values()) if (n.channelKey === s.key && n.role && holds(n.role, s.ball) && (!holder || n.lastAt > holder.lastAt)) holder = n;
|
|
123
|
+
if (holder) held.set(holder.id, Math.max(held.get(holder.id) || 0, changed.get(s.key) || 0));
|
|
124
|
+
}
|
|
125
|
+
return held;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Task titles from the briefs each owner session gave its subagents: for agents without their own transcript, and per stream. */
|
|
129
|
+
function applyBriefs(nodes, channels, owner, index) {
|
|
130
|
+
const byKey = new Map();
|
|
131
|
+
for (const sid of new Set(owner.values())) {
|
|
132
|
+
const s = nodes.get(sid);
|
|
133
|
+
if (!s || !s.file) continue;
|
|
134
|
+
for (const [k, roles] of channelsOwned(s.file, index).briefs) byKey.set(k, roles);
|
|
135
|
+
}
|
|
136
|
+
const titles = new Map();
|
|
137
|
+
for (const c of channels) {
|
|
138
|
+
const roles = byKey.get(c.key);
|
|
139
|
+
c.title = streamTitle(c) || (roles && [...roles.values()].map((b) => b.title).find(Boolean)) || null;
|
|
140
|
+
titles.set(c.key, c.title);
|
|
141
|
+
}
|
|
142
|
+
for (const n of nodes.values()) {
|
|
143
|
+
if (n.kind !== 'agent' || n.taskTitle || !n.channelKey) continue;
|
|
144
|
+
const roles = byKey.get(n.channelKey);
|
|
145
|
+
const b = roles && (roles.get(n.role) || [...roles.values()].find((x) => n.role && x.role && (x.role.endsWith(n.role) || n.role.endsWith(x.role))));
|
|
146
|
+
n.taskTitle = (b && b.title) || titles.get(n.channelKey) || null;
|
|
147
|
+
if (b && (!n.task || n.synthetic)) n.task = b.code;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A stream's title: the heading of brief.md beside its channel, else of the spec its KICKOFF names. */
|
|
152
|
+
function streamTitle(c) {
|
|
153
|
+
const dir = path.dirname(c.file);
|
|
154
|
+
const brief = firstHeading(path.join(dir, 'brief.md'));
|
|
155
|
+
if (brief) return brief;
|
|
156
|
+
const kickoff = c.posts.find((p) => /^KICKOFF/.test(p.type.toUpperCase()));
|
|
157
|
+
const spec = kickoff && kickoff.text.match(/(docs\/[A-Za-z0-9._/-]+\.md)/);
|
|
158
|
+
if (!spec || spec[1].split('/').includes('..')) return null;
|
|
159
|
+
const repoDir = path.resolve(dir, '..', '..');
|
|
160
|
+
return firstHeading(path.join(repoDir, spec[1]));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const postCache = new Map();
|
|
164
|
+
/** A channel's posts, re-parsed only when the file changed. */
|
|
165
|
+
function postsOf(c) {
|
|
166
|
+
let size = 0;
|
|
167
|
+
try { size = fs.statSync(c.file).size; } catch { return []; }
|
|
168
|
+
const hit = postCache.get(c.file);
|
|
169
|
+
if (hit && hit.mtime === c.mtime && hit.size === size) return hit.posts;
|
|
170
|
+
let posts = [];
|
|
171
|
+
try { posts = parsePosts(fs.readFileSync(c.file, 'utf8'), c.mtime); } catch { /* unreadable */ }
|
|
172
|
+
if (postCache.size > 2000) postCache.clear();
|
|
173
|
+
postCache.set(c.file, { mtime: c.mtime, size, posts });
|
|
174
|
+
return posts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The whole picture for the last `hours`. */
|
|
178
|
+
/** Hub-owned agents (runs): their sessions may have a Cursor transcript (relabel it) or none (Claude, other CLIs). */
|
|
179
|
+
function adoptRuns(nodes, runs, since) {
|
|
180
|
+
for (const r of runs) {
|
|
181
|
+
if (r.state === 'stopped' && (r.lastAt || r.createdAt) < since) continue;
|
|
182
|
+
const prev = nodes.get(r.id);
|
|
183
|
+
const channel = r.stream ? r.stream.split('/').pop() : null;
|
|
184
|
+
nodes.set(r.id, {
|
|
185
|
+
...(prev || { bytes: 0, tokensEst: null }),
|
|
186
|
+
id: r.id, kind: 'agent', role: r.role, parent: r.parent || MANAGER, channelKey: r.stream || null, channel, task: channel,
|
|
187
|
+
repo: path.basename(r.workspace), label: titleCase(r.role), title: r.name || `${r.role} (${r.driver}, hub-owned)`,
|
|
188
|
+
startedAt: (prev && prev.startedAt) || r.createdAt, lastAt: Math.max((prev && prev.lastAt) || 0, r.lastAt || r.createdAt),
|
|
189
|
+
hubRun: { driver: r.driver, state: r.state, turns: r.turns || 0, costUsd: r.costUsd || 0, lastError: r.lastError || null },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function collect({ hours = 6, runs = [], aliases = [] } = {}) {
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
const since = now - hours * 3600e3;
|
|
197
|
+
const channels = channelFiles(CHANNEL_ROOTS, since);
|
|
198
|
+
const index = new Map();
|
|
199
|
+
for (const c of channels) {
|
|
200
|
+
c.posts = postsOf(c);
|
|
201
|
+
if (!index.has(c.task)) index.set(c.task, []);
|
|
202
|
+
index.get(c.task).push(c.key);
|
|
203
|
+
}
|
|
204
|
+
const nodes = scanTranscripts(since, index);
|
|
205
|
+
adoptRuns(nodes, runs, since);
|
|
206
|
+
// The headless Hub Manager has a Cursor transcript too, but it is the single central manager node, not a user
|
|
207
|
+
// delivery. Any subagents it starts hang from that manager node.
|
|
208
|
+
let ownChats = new Set();
|
|
209
|
+
try { ownChats = managerChats(JSON.parse(fs.readFileSync(path.join(HUB, 'state', 'manager.json'), 'utf8'))); } catch { /* manager not configured */ }
|
|
210
|
+
for (const chat of ownChats) nodes.delete(chat);
|
|
211
|
+
for (const n of nodes.values()) if (ownChats.has(n.parent)) n.parent = MANAGER;
|
|
212
|
+
const owner = assignOwners(nodes, channels, index, now);
|
|
213
|
+
/** A stream run by a subagent belongs to the session at the top of its chain. */
|
|
214
|
+
const rootOf = (id) => { let n = nodes.get(id); for (let i = 0; n && n.kind === 'agent' && n.parent && i < 8; i++) n = nodes.get(n.parent); return n && n.kind === 'session' ? n.id : null; };
|
|
215
|
+
for (const [k, id] of owner) { const n = nodes.get(id); if (n && n.kind === 'agent') { const root = rootOf(id); if (root) owner.set(k, root); else owner.delete(k); } }
|
|
216
|
+
for (const n of nodes.values()) {
|
|
217
|
+
n.status = statusOf(n.lastAt, now);
|
|
218
|
+
n.tokensEst = n.parent && n.bytes < STUB_BYTES ? null : Math.round(n.bytes / 4);
|
|
219
|
+
}
|
|
220
|
+
for (const c of channels) {
|
|
221
|
+
c.checkout = gitInfo(path.resolve(path.dirname(c.file), '..', '..'));
|
|
222
|
+
const kickoff = c.posts.find((p) => /^KICKOFF/.test(p.type.toUpperCase()));
|
|
223
|
+
c.planned = kickoff ? plannedCheckout(kickoff.text) : null;
|
|
224
|
+
c.git = taskCheckout(c.checkout, c.planned);
|
|
225
|
+
}
|
|
226
|
+
const streams = new Map(channels.map((c) => [c.key, streamView(c, { aliases })]));
|
|
227
|
+
addBallHolders(nodes, streams, owner, now);
|
|
228
|
+
const seenAt = ballHeldAt(nodes, streams, channels);
|
|
229
|
+
for (const n of nodes.values()) if (n.hubRun && n.hubRun.state !== 'stopped') seenAt.set(n.id, now);
|
|
230
|
+
|
|
231
|
+
const events = [];
|
|
232
|
+
const linkCount = new Map();
|
|
233
|
+
const bump = (a, b, kind) => { const k = `${a}|${b}|${kind}`; linkCount.set(k, (linkCount.get(k) || 0) + 1); };
|
|
234
|
+
const byKeyRole = new Map();
|
|
235
|
+
for (const n of nodes.values()) if (n.channelKey && n.role) {
|
|
236
|
+
const k = `${n.channelKey}|${n.role}`;
|
|
237
|
+
const prev = byKeyRole.get(k);
|
|
238
|
+
if (!prev || prev.lastAt < n.lastAt) byKeyRole.set(k, n);
|
|
239
|
+
}
|
|
240
|
+
const derived = [];
|
|
241
|
+
const derivedIds = new Map();
|
|
242
|
+
for (const c of channels) {
|
|
243
|
+
const posts = c.posts.filter((p) => p.at >= since);
|
|
244
|
+
if (!posts.length) continue;
|
|
245
|
+
const roles = [...new Set(posts.map((p) => p.role))];
|
|
246
|
+
const architect = owner.get(c.key) || null;
|
|
247
|
+
const nodeFor = (role) => {
|
|
248
|
+
if (role === 'USER') return 'user';
|
|
249
|
+
const hit = byKeyRole.get(`${c.key}|${role}`);
|
|
250
|
+
if (hit) return hit.id;
|
|
251
|
+
if (role === 'ARCHITECT' && architect) return architect;
|
|
252
|
+
const id = `${c.key}:${role}`;
|
|
253
|
+
if (!nodes.has(id)) {
|
|
254
|
+
const mine = posts.filter((p) => p.role === role);
|
|
255
|
+
const last = mine[mine.length - 1].at;
|
|
256
|
+
nodes.set(id, { id, kind: 'agent', role, task: c.task, channel: c.task, channelKey: c.key, repo: c.repo, label: titleCase(role),
|
|
257
|
+
title: `${role} on ${c.task} (seen only in its channel)`, parent: architect, startedAt: mine[0].at, lastAt: last, bytes: 0, tokensEst: null, status: statusOf(last, now), synthetic: true });
|
|
258
|
+
}
|
|
259
|
+
return id;
|
|
260
|
+
};
|
|
261
|
+
for (const p of posts) {
|
|
262
|
+
const from = nodeFor(p.role);
|
|
263
|
+
seenAt.set(from, Math.max(seenAt.get(from) || 0, p.at));
|
|
264
|
+
for (const r of recipients(p.role, roles)) {
|
|
265
|
+
const to = nodeFor(r);
|
|
266
|
+
events.push({ at: p.at, from, to, type: p.type, text: p.text.slice(0, 300), channel: c.task });
|
|
267
|
+
bump(from, to, 'msg');
|
|
268
|
+
}
|
|
269
|
+
if (!REQUEST_HINT.test(p.head)) continue;
|
|
270
|
+
// Posts share a second often enough; each is its own request.
|
|
271
|
+
const base = `${DERIVED_REQUEST_PREFIX}${c.key}-${p.at}`;
|
|
272
|
+
const seq = derivedIds.get(base) || 0;
|
|
273
|
+
derivedIds.set(base, seq + 1);
|
|
274
|
+
derived.push({ id: seq ? `${base}-${seq}` : base, title: p.text.replace(/^[^—]*— ?/, '').slice(0, 120), kind: 'platform', from: [from], status: 'unfiled', at: p.at, channel: c.task });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const requests = [...readRequests().filter((r) => r.at >= since), ...derived];
|
|
279
|
+
for (const r of requests) for (const f of r.from) if (nodes.has(f)) bump(f, MANAGER, 'request');
|
|
280
|
+
nodes.set(MANAGER, { id: MANAGER, kind: 'manager', label: 'Manager', title: 'Owns shared changes: platform, common scripts, rules', status: 'active', startedAt: since, lastAt: now, tokensEst: null });
|
|
281
|
+
|
|
282
|
+
const links = [];
|
|
283
|
+
for (const n of nodes.values()) if (n.parent && nodes.has(n.parent)) links.push({ source: n.parent, target: n.id, kind: 'spawn', count: 1 });
|
|
284
|
+
for (const [k, count] of linkCount) {
|
|
285
|
+
const [source, target, kind] = k.split('|');
|
|
286
|
+
if (nodes.has(source) && nodes.has(target) && source !== target) links.push({ source, target, kind, count });
|
|
287
|
+
}
|
|
288
|
+
const sent = new Map();
|
|
289
|
+
const got = new Map();
|
|
290
|
+
for (const e of events) { sent.set(e.from, (sent.get(e.from) || 0) + 1); got.set(e.to, (got.get(e.to) || 0) + 1); }
|
|
291
|
+
events.sort((a, b) => a.at - b.at);
|
|
292
|
+
|
|
293
|
+
applyBriefs(nodes, channels, owner, index);
|
|
294
|
+
for (const c of channels) { const v = streams.get(c.key); if (v) v.title = c.title || null; }
|
|
295
|
+
for (const n of nodes.values()) {
|
|
296
|
+
if (n.kind !== 'agent' && !(n.kind === 'session' && !n.parent)) continue;
|
|
297
|
+
const p = personaFor(n.id, { role: n.kind === 'agent' ? n.role : 'ARCHITECT', channel: n.channelKey || null, now: Math.min(now, n.startedAt || now) });
|
|
298
|
+
n.persona = { name: p.name, first: p.first, username: p.username, seed: p.seed, personality: p.personality || null };
|
|
299
|
+
}
|
|
300
|
+
savePersonas();
|
|
301
|
+
const sessions = [...nodes.values()].filter((n) => n.kind === 'session' && !n.parent && n.file);
|
|
302
|
+
const kids = new Map();
|
|
303
|
+
for (const n of nodes.values()) if (n.parent && n.kind === 'agent') { if (!kids.has(n.parent)) kids.set(n.parent, []); kids.get(n.parent).push(n); }
|
|
304
|
+
const tails = new Map(sessions.map((s) => [s.id, sessionTail(s.file)]));
|
|
305
|
+
const deliveries = buildDeliveries({ sessions, kids, streams, owner, tails, acks: readAccepted(), now });
|
|
306
|
+
const names = chatTitles(deliveries.map((d) => d.id));
|
|
307
|
+
for (const d of deliveries) {
|
|
308
|
+
d.name = names.get(d.id) || null;
|
|
309
|
+
const session = nodes.get(d.id);
|
|
310
|
+
if (session) Object.assign(session, {
|
|
311
|
+
role: 'ARCHITECT', nodeType: 'delivery', chatTitle: d.name || d.title, label: d.name || d.title, promptTitle: d.title,
|
|
312
|
+
deliveryId: d.id, project: d.projects.join(', '),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const nodeList = [...nodes.values()];
|
|
316
|
+
const agentRows = buildAgents({ nodes: nodeList, streams, owner, deliveries, tails, now });
|
|
317
|
+
for (const row of agentRows) withLive(row, now);
|
|
318
|
+
const RUN_STATUS = { working: 'working', queued: 'working', idle: 'waiting', error: 'stuck', stopped: 'done' };
|
|
319
|
+
for (const row of agentRows) {
|
|
320
|
+
const n = nodes.get(row.id);
|
|
321
|
+
if (!n || !n.hubRun) continue;
|
|
322
|
+
row.hubRun = n.hubRun;
|
|
323
|
+
row.status = RUN_STATUS[n.hubRun.state] || row.status;
|
|
324
|
+
row.reach = { ...row.reach, state: n.hubRun.state === 'stopped' ? 'finished' : 'connected', chatInbox: n.hubRun.state !== 'stopped', hubRun: n.hubRun.driver };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const list = nodeList.map(({ file, bytes, ...n }) => ({ ...n, msgsOut: sent.get(n.id) || 0, msgsIn: got.get(n.id) || 0 }));
|
|
328
|
+
const people = list.filter((n) => n.kind !== 'manager');
|
|
329
|
+
return {
|
|
330
|
+
generatedAt: now, hours, deliveries, agents: agentRows,
|
|
331
|
+
totals: {
|
|
332
|
+
sessions: people.filter((n) => n.kind === 'session').length,
|
|
333
|
+
agents: people.filter((n) => n.kind === 'agent').length,
|
|
334
|
+
active: people.filter((n) => n.status === 'active').length,
|
|
335
|
+
messages: events.length,
|
|
336
|
+
tokensEst: people.reduce((s, n) => s + (n.tokensEst || 0), 0),
|
|
337
|
+
requests: requests.length,
|
|
338
|
+
},
|
|
339
|
+
totalsBasis: totalsBasis(people, events, seenAt),
|
|
340
|
+
nodes: list, links, events: events.slice(-400), requests: requests.sort((a, b) => b.at - a.at),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// agents-hub lib/deliveries.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Deliveries and agent rows from analysed work streams. Pure: everything it needs is passed in, so it is tested
|
|
3
|
+
// with fixtures. A delivery is one orchestrating session; its work streams are the channels it owns.
|
|
4
|
+
import { STEPS, analyseStream, holds, movesWork, postStep } from './channels.mjs';
|
|
5
|
+
import { closedReason, closedText, roleOutcome } from './closure.mjs';
|
|
6
|
+
|
|
7
|
+
const APPS = ['agentforce-builder', 'agentforce-review', 'agent-studio', 'datacloud-builder', 'agentforce', 'ai-hub', 'insights', 'org-map'];
|
|
8
|
+
const IDLE_MS = 10 * 60e3;
|
|
9
|
+
export const STUCK_MS = 30 * 60e3;
|
|
10
|
+
/** Pulse 1.1.2 (E4): a newest post stamped this far past the file's last write is future-dated on the Board. */
|
|
11
|
+
const FUTURE_MS = 5 * 60e3;
|
|
12
|
+
const REPLY_MAX_AGE_MS = 6 * 3600e3;
|
|
13
|
+
const ASKS_REPLY = /\?\s*$|\b(should i|want me to|shall i|do you want|your call|let me know|which (one|option)|approve)\b/i;
|
|
14
|
+
|
|
15
|
+
export function projectOf(repo) {
|
|
16
|
+
if (!repo) return null;
|
|
17
|
+
if (/^AgentforceVoice/.test(repo)) return 'platform';
|
|
18
|
+
const rest = repo.replace(/^agentpulse-/, '');
|
|
19
|
+
return APPS.find((a) => rest === a || rest.startsWith(`${a}-`)) || rest;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').replace(/[*`#]/g, '').trim();
|
|
23
|
+
const cut = (s, max) => { const t = clean(s); return t.length > max ? t.slice(0, max - 1) + '…' : t; };
|
|
24
|
+
const firstSentence = (s, max = 200) => { const t = clean(s); const m = t.match(/^(.{20,}?[.?!])(\s|$)/); return cut(m ? m[1] : t, max); };
|
|
25
|
+
const tailText = (s, max = 240) => { const t = clean(s); return t.length > max ? '…' + t.slice(-max + 1) : t; };
|
|
26
|
+
const mins = (ms) => Math.max(1, Math.round(ms / 60e3));
|
|
27
|
+
/** A post's substance: what follows the header's "TYPE #id —" prefix. */
|
|
28
|
+
const detail = (text) => { const t = clean(text).replace(/^FINAL:? ?FAIL\b[\s:—-]*/i, ''); const i = t.indexOf(' — '); return i > 0 && i < 90 ? t.slice(i + 3) : t; };
|
|
29
|
+
|
|
30
|
+
/** What the person holding the ball is doing, in words. */
|
|
31
|
+
function ballText(a) {
|
|
32
|
+
if (a.done) return closedText(a.outcome);
|
|
33
|
+
if (a.ball === 'YOU') return 'Waiting for your decision';
|
|
34
|
+
if (a.failed) return a.failed.role === 'USER' ? 'You asked for changes; the coder is on it' : 'Final review failed; the coder is reworking it';
|
|
35
|
+
if (a.blockers.length) {
|
|
36
|
+
if (a.ball === 'CODER') return `Coder is fixing ${a.blockers.length} blocker${a.blockers.length > 1 ? 's' : ''}`;
|
|
37
|
+
if (a.ball === 'REVIEWER') return 'Waiting for the reviewer to verify the fix';
|
|
38
|
+
if (a.ball === 'AUDITOR') return 'Waiting for the auditor to verify the fix';
|
|
39
|
+
if (a.ball === 'ARCHITECT') return 'Deploy blocked; the architect is on it';
|
|
40
|
+
}
|
|
41
|
+
if (a.ball === 'ARCHITECT') {
|
|
42
|
+
if (a.question) return 'Coder asked the architect a question';
|
|
43
|
+
return a.step >= 6 ? 'Waiting for the architect to merge or release' : 'Waiting for the architect to deploy and post evidence';
|
|
44
|
+
}
|
|
45
|
+
if (a.ball === 'REVIEWER') return a.step >= 5 ? 'Waiting for the reviewer\u2019s final call' : 'Waiting for review';
|
|
46
|
+
if (a.ball === 'AUDITOR') return 'Waiting for the auditor';
|
|
47
|
+
if (a.ball === 'CODER') return a.step <= 3 ? 'Coder is building' : 'Coder is addressing review';
|
|
48
|
+
return 'Working';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A channel's posts → the summary the UI shows for one work stream. */
|
|
52
|
+
export function streamView(c, { aliases = [] } = {}) {
|
|
53
|
+
const a = analyseStream(c.posts, { aliases });
|
|
54
|
+
const last = c.posts[c.posts.length - 1];
|
|
55
|
+
const moved = c.posts.filter(movesWork);
|
|
56
|
+
/** Positions are 1-based places in the channel: the order the hub compares by, since headers carry agents' clocks. */
|
|
57
|
+
const seqs = c.posts.map((p, i) => (movesWork(p) ? i + 1 : 0)).filter(Boolean);
|
|
58
|
+
const movedSeqByRole = {};
|
|
59
|
+
for (const i of seqs) movedSeqByRole[c.posts[i - 1].role] = i;
|
|
60
|
+
return {
|
|
61
|
+
key: c.key, channel: c.task, title: c.title || null, repo: c.repo, project: projectOf(c.repo), file: c.file,
|
|
62
|
+
git: c.git || null,
|
|
63
|
+
step: a.step, stepName: a.stepName, round: a.round, done: a.done, outcome: a.done ? a.outcome || 'passed' : null,
|
|
64
|
+
failed: a.failed ? firstSentence(detail(a.failed.text), 200) : null, failedBy: a.failed ? a.failed.role : null,
|
|
65
|
+
blockers: a.blockers.map((b) => ({ id: b.id, role: b.role, at: b.at, text: firstSentence(detail(b.text), 220) })),
|
|
66
|
+
needsUser: a.needsUser && { at: a.needsUser.at, text: a.needsUser.text, ref: a.needsUser.ids[0] || null },
|
|
67
|
+
openAsks: a.asks.length,
|
|
68
|
+
question: a.question && { at: a.question.at, text: firstSentence(a.question.text, 200) },
|
|
69
|
+
final: a.lastFinal && { at: a.lastFinal.at, text: cut(a.lastFinal.text, 160) },
|
|
70
|
+
evidence: a.lastEvidence && { at: a.lastEvidence.at, text: cut(detail(a.lastEvidence.text), 200) },
|
|
71
|
+
ball: a.ball, ballAt: a.ballAt, ballText: ballText(a), lastByRole: a.lastByRole,
|
|
72
|
+
lastAt: Math.max(c.mtime, last ? last.at : 0), lastPost: last && { role: last.role, type: last.type, text: cut(last.text, 200), at: last.at, writtenAt: c.mtime, ...(last.at - c.mtime > FUTURE_MS ? { futureDated: true } : {}) },
|
|
73
|
+
agentAt: moved.length ? moved[moved.length - 1].at : null, movedByRole: Object.fromEntries(moved.map((p) => [p.role, p.at])),
|
|
74
|
+
agentSeq: seqs.length ? seqs[seqs.length - 1] : 0, movedSeqByRole,
|
|
75
|
+
stepSeq: STEPS.map((_, i) => seqs.reduce((at, n) => (postStep(c.posts[n - 1]) >= i ? n : at), 0)),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* sessions: top-level session nodes; kids: Map session id → its agent nodes; streams: Map key → streamView;
|
|
81
|
+
* owner: Map stream key → session id; tails: Map session id → sessionTail(); acks: Map delivery id → when you accepted
|
|
82
|
+
* its result.
|
|
83
|
+
*/
|
|
84
|
+
export function buildDeliveries({ sessions, kids, streams, owner, tails, acks = new Map(), now }) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const s of sessions) {
|
|
87
|
+
const agents = kids.get(s.id) || [];
|
|
88
|
+
const mine = [...owner].filter(([, sid]) => sid === s.id).map(([k]) => streams.get(k)).filter(Boolean).sort((a, b) => b.lastAt - a.lastAt);
|
|
89
|
+
const tail = tails.get(s.id) || { state: 'unknown', lastText: '' };
|
|
90
|
+
const active = agents.filter((a) => now - a.lastAt < IDLE_MS).length;
|
|
91
|
+
const lastAt = Math.max(s.lastAt, ...mine.map((x) => x.lastAt), ...agents.map((a) => a.lastAt));
|
|
92
|
+
const open = mine.filter((x) => !x.done);
|
|
93
|
+
const stepCounts = STEPS.map((_, i) => open.filter((x) => x.step === i).length);
|
|
94
|
+
const step = open.length ? stepCounts.lastIndexOf(Math.max(...stepCounts)) : mine.length ? STEPS.length - 1 : null;
|
|
95
|
+
const idle = now - lastAt > IDLE_MS && !active;
|
|
96
|
+
const titles = [...new Set([...mine.map((x) => x.title), ...agents.map((a) => a.taskTitle)].filter(Boolean))];
|
|
97
|
+
|
|
98
|
+
const lastFinalAt = Math.max(0, ...mine.map((x) => (x.final ? x.final.at : 0)));
|
|
99
|
+
const accepted = (acks.get(s.id) || 0) >= lastFinalAt && lastFinalAt > 0;
|
|
100
|
+
/** When the holder of a stream's ball last did anything: its agents, or this session when the architect holds it. */
|
|
101
|
+
const holderLast = (x) => (x.ball === 'ARCHITECT' ? s.lastAt : Math.max(0, ...agents.filter((a) => a.channelKey === x.key && holds(a.role, x.ball)).map((a) => a.lastAt)));
|
|
102
|
+
const quietFor = (x) => now - Math.max(x.ballAt || x.lastAt, holderLast(x));
|
|
103
|
+
|
|
104
|
+
let ask = null;
|
|
105
|
+
const nu = open.find((x) => x.needsUser);
|
|
106
|
+
if (tail.state === 'asking') ask = { kind: 'question', label: 'Question for you', decide: tail.question.prompt, options: tail.question.options, more: tail.question.more, then: 'The session continues as soon as you answer in its Cursor chat.' };
|
|
107
|
+
else if (nu) ask = { kind: 'decision', label: 'Design decision', decide: firstSentence(nu.needsUser.text, 260), full: clean(nu.needsUser.text), stream: nu.channel, key: nu.key, ref: nu.needsUser.ref, at: nu.needsUser.at, more: nu.openAsks - 1, then: 'The architect applies your decision and the coder continues.' };
|
|
108
|
+
else if (mine.length && !open.length && idle && !accepted && mine.every((x) => (x.outcome || 'passed') === 'passed')) {
|
|
109
|
+
ask = { kind: 'result', label: 'Result ready', decide: `All ${mine.length} work stream${mine.length > 1 ? 's' : ''} passed final review. Check the proof, then accept.`, then: 'Release or merge is yours: agents never push.',
|
|
110
|
+
proof: mine.flatMap((x) => [x.final && `${x.title || x.channel}: ${x.final.text}`, x.evidence && `Evidence: ${x.evidence.text}`]).filter(Boolean).slice(0, 12) };
|
|
111
|
+
} else if (tail.state === 'turn-ended' && idle && now - lastAt < REPLY_MAX_AGE_MS && ASKS_REPLY.test(tail.lastText.slice(-400))) ask = { kind: 'reply', label: 'Waiting for your reply', decide: tailText(tail.lastText), then: 'The session picks up from your reply.' };
|
|
112
|
+
|
|
113
|
+
const failed = open.find((x) => x.failed && x.failedBy !== 'USER');
|
|
114
|
+
const blocked = open.find((x) => x.blockers.length);
|
|
115
|
+
const stuck = open.filter((x) => x.ball && x.ball !== 'YOU' && quietFor(x) > STUCK_MS).sort((a, b) => quietFor(b) - quietFor(a))[0];
|
|
116
|
+
let status; let reason;
|
|
117
|
+
if (ask) { status = 'needs-you'; reason = ask.label; }
|
|
118
|
+
else if (failed) { status = 'blocked'; reason = `${failed.channel}: final review failed. ${failed.failed}`; }
|
|
119
|
+
else if (blocked) { const b = blocked.blockers[0]; status = 'blocked'; reason = `${blocked.channel}: blocker #${b.id} from the ${b.role.toLowerCase()}: ${b.text}`; }
|
|
120
|
+
else if (mine.length && !open.length) { status = 'done'; reason = accepted ? 'Accepted' : closedReason(mine.map((x) => x.outcome)); }
|
|
121
|
+
else if (!mine.length && tail.state === 'turn-ended' && idle) { status = 'done'; reason = 'Answered'; }
|
|
122
|
+
else if (stuck) { status = 'stuck'; reason = `${stuck.channel}: ${stuck.ballText.toLowerCase()}, nothing for ${mins(quietFor(stuck))} min`; }
|
|
123
|
+
else if (open.length) { const x = open[0]; status = 'on-track'; reason = `${x.channel}: ${x.ballText.toLowerCase()}`; }
|
|
124
|
+
else { status = 'on-track'; reason = active ? `${active} agent${active > 1 ? 's' : ''} working` : 'Working'; }
|
|
125
|
+
|
|
126
|
+
out.push({
|
|
127
|
+
id: s.id, title: firstSentence(s.title, 110), subtitle: titles.slice(0, 3).join(' · '), projects: [...new Set(mine.map((x) => x.project).filter(Boolean))],
|
|
128
|
+
streams: mine.map(({ lastByRole, movedByRole, ...x }) => x), step, stepName: step == null ? null : STEPS[step], stepCounts, done: mine.length - open.length,
|
|
129
|
+
status, reason, ask, agents: agents.length, active, tokens: [s, ...agents].reduce((n, a) => n + (a.tokensEst || 0), 0),
|
|
130
|
+
startedAt: s.startedAt, lastAt, sessionState: tail.state,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const rank = { 'needs-you': 0, blocked: 1, stuck: 2, 'on-track': 3, done: 4 };
|
|
134
|
+
return out.sort((a, b) => rank[a.status] - rank[b.status] || b.lastAt - a.lastAt);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One row per agent: its task, what it last did, who it waits on, and whether it is working, waiting, stuck or done. */
|
|
138
|
+
export function buildAgents({ nodes, streams, owner, deliveries, tails = new Map(), now }) {
|
|
139
|
+
const byDelivery = new Map(deliveries.map((d) => [d.id, d]));
|
|
140
|
+
const rows = [];
|
|
141
|
+
for (const n of nodes) {
|
|
142
|
+
if (n.kind !== 'agent') continue;
|
|
143
|
+
const s = n.channelKey && streams.get(n.channelKey);
|
|
144
|
+
const deliveryId = (s && owner.get(s.key)) || n.parent || null;
|
|
145
|
+
const d = deliveryId && byDelivery.get(deliveryId);
|
|
146
|
+
const byRole = (m) => s && n.role && (m[n.role] || Object.entries(m).find(([r]) => r && (r.endsWith(n.role) || n.role.endsWith(r)))?.[1]);
|
|
147
|
+
const mine = byRole(s ? s.lastByRole : {});
|
|
148
|
+
const lastAct = Math.max(n.lastAt, mine ? mine.at : 0);
|
|
149
|
+
let status; let waitingOn = null;
|
|
150
|
+
if (!s) status = now - n.lastAt < IDLE_MS ? 'working' : 'done';
|
|
151
|
+
else if (s.done || roleOutcome(mine)) status = 'done';
|
|
152
|
+
else if (holds(n.role, s.ball)) status = now - Math.max(s.ballAt || 0, lastAct) > STUCK_MS ? 'stuck' : 'working';
|
|
153
|
+
else if (s.ball) { status = 'waiting'; waitingOn = s.ball; }
|
|
154
|
+
else status = now - lastAct < IDLE_MS ? 'working' : 'idle';
|
|
155
|
+
rows.push({
|
|
156
|
+
id: n.id, role: n.role, label: n.label, task: n.taskTitle || n.task || n.channel || null, code: n.task || null,
|
|
157
|
+
deliveryId, deliveryTitle: d ? d.title : null, streamKey: s ? s.key : null, channel: n.channel, project: s ? s.project : projectOf(n.repo),
|
|
158
|
+
status, waitingOn, doing: mine ? { type: mine.type, text: cut(mine.text, 180), at: mine.at } : null, lastAt: lastAct, startedAt: n.startedAt,
|
|
159
|
+
tokensEst: n.tokensEst, seenOnlyInChannel: !!n.synthetic, persona: n.persona || null, parentId: n.parent || null, movedAt: byRole(s ? s.movedByRole : {}) || null, movedSeq: byRole(s ? s.movedSeqByRole : {}) || 0,
|
|
160
|
+
reach: reachOf(s ? { key: s.key, channel: s.task } : null, n.lastAt, tails.get(n.id), status === 'done', now),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
for (const d of deliveries) {
|
|
164
|
+
const s = nodes.find((n) => n.id === d.id);
|
|
165
|
+
if (s) rows.push(architectRow(s, d, tails.get(s.id), now));
|
|
166
|
+
}
|
|
167
|
+
const rank = { stuck: 0, working: 1, waiting: 2, idle: 3, done: 4 };
|
|
168
|
+
return rows.sort((a, b) => rank[a.status] - rank[b.status] || b.lastAt - a.lastAt);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether a message from you can reach an agent. A Cursor chat cannot be pushed into from outside: it only reads its
|
|
173
|
+
* channel during its own turn. So a message lands in the channel it reads, and is heard only while the session runs.
|
|
174
|
+
*/
|
|
175
|
+
function reachOf(inbox, lastAt, tail, finished, now) {
|
|
176
|
+
const state = finished ? 'finished' : !inbox ? 'no-inbox' : tail && tail.state === 'turn-ended' ? 'turn-ended'
|
|
177
|
+
: tail && tail.state === 'asking' ? 'asking' : now - lastAt < IDLE_MS ? 'running' : 'quiet';
|
|
178
|
+
return { streamKey: inbox ? inbox.key : null, channel: inbox ? inbox.channel : null, state, lastAt };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A session with no work streams yet: still a live Cursor chat you can message. Its state is its chat's, never
|
|
183
|
+
* "finished" — typing in it (or a message at its next turn) carries on.
|
|
184
|
+
*/
|
|
185
|
+
function sessionRow(s, d, tail, now, awake) {
|
|
186
|
+
const asking = tail && tail.state === 'asking';
|
|
187
|
+
const working = tail && tail.state === 'working' && awake;
|
|
188
|
+
return {
|
|
189
|
+
id: s.id, role: 'ARCHITECT', label: 'Architect', isArchitect: true, task: d.subtitle || d.title, code: null, deliveryId: d.id, deliveryTitle: d.title,
|
|
190
|
+
streamKey: null, channel: null, project: d.projects.join(', '), status: asking ? 'waiting' : working ? 'working' : 'idle', waitingOn: asking ? 'YOU' : null,
|
|
191
|
+
doing: { type: asking ? 'Question for you' : 'Chat', text: d.reason, at: s.lastAt },
|
|
192
|
+
lastAt: s.lastAt, startedAt: s.startedAt, tokensEst: s.tokensEst, seenOnlyInChannel: false, persona: s.persona || null,
|
|
193
|
+
reach: { ...reachOf({ key: null, channel: null }, s.lastAt, tail, false, now), chat: d.title, workspace: d.projects[0] || null },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The orchestrating session as the ARCHITECT of its streams: what it owes (deploy, evidence, answers) and whether it is on it. */
|
|
198
|
+
function architectRow(s, d, tail, now) {
|
|
199
|
+
const open = d.streams.filter((x) => !x.done);
|
|
200
|
+
const owed = open.filter((x) => x.ball === 'ARCHITECT');
|
|
201
|
+
const awake = now - s.lastAt < IDLE_MS;
|
|
202
|
+
if (!d.streams.length) return sessionRow(s, d, tail, now, awake);
|
|
203
|
+
let status; let waitingOn = null;
|
|
204
|
+
if (!open.length) status = 'done';
|
|
205
|
+
else if (owed.length) status = awake || now - Math.min(...owed.map((x) => x.ballAt || x.lastAt)) < STUCK_MS ? 'working' : 'stuck';
|
|
206
|
+
else {
|
|
207
|
+
status = 'waiting';
|
|
208
|
+
const balls = open.map((x) => x.ball).filter(Boolean);
|
|
209
|
+
waitingOn = balls.sort((a, b) => balls.filter((x) => x === b).length - balls.filter((x) => x === a).length)[0] || null;
|
|
210
|
+
}
|
|
211
|
+
const inbox = owed[0] || open.slice().sort((a, b) => b.lastAt - a.lastAt)[0] || null;
|
|
212
|
+
const owes = [...new Set(owed.map((x) => x.ballText.replace(/^Waiting for the architect to /, '').replace(/^Coder asked the architect a question$/, 'answer a question')))];
|
|
213
|
+
return {
|
|
214
|
+
id: s.id, role: 'ARCHITECT', label: 'Architect', isArchitect: true, task: d.subtitle || d.title, code: null, deliveryId: d.id, deliveryTitle: d.title,
|
|
215
|
+
streamKey: null, channel: null, project: d.projects.join(', '), status, waitingOn,
|
|
216
|
+
doing: owed.length ? { type: `Owes ${owed.length}`, text: `${owed.map((x) => x.channel).join(', ')}: ${owes.join('; ')}`, at: Math.max(...owed.map((x) => x.ballAt || x.lastAt)) }
|
|
217
|
+
: { type: 'Coordinating', text: `${open.length} open stream${open.length === 1 ? '' : 's'}`, at: s.lastAt },
|
|
218
|
+
lastAt: s.lastAt, startedAt: s.startedAt, tokensEst: s.tokensEst, seenOnlyInChannel: false, persona: s.persona || null,
|
|
219
|
+
movedAt: Math.max(0, ...d.streams.map((x) => x.agentAt || 0)) || null,
|
|
220
|
+
reach: { ...reachOf(inbox, s.lastAt, tail, !open.length, now), chat: d.title, workspace: d.projects[0] || null },
|
|
221
|
+
};
|
|
222
|
+
}
|