@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.2
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 +46 -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 +39 -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 +19 -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 +376 -0
- package/src/lib/cutoverWiring.js +137 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +16 -2
- 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 +61 -0
- 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 +140 -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,92 @@
|
|
|
1
|
+
// Pulse 1.1.1 (F3): the terminal headers of agents-hub.mdc and delivery-discipline.mdc, and what each one ends. Pure
|
|
2
|
+
// and dependency-free: the plugin (src/hub/lib/closure.mjs) and the cloud (bll/projections/closure.js) hold this file
|
|
3
|
+
// byte for byte, and both test it against the same test/fixtures/closure-cases.json.
|
|
4
|
+
|
|
5
|
+
/** What keeps a FINAL header from being a pass (channels.mjs at 6b2fb0b). */
|
|
6
|
+
const FINAL_HOLD = /\bFAIL(ED|S)?\b|\bBLOCKED\b|\bPENDING\b|\bWAITING\b|\bSTARTING\b|\bNOT (A )?(RELEASE )?SIGN-OFF\b/;
|
|
7
|
+
const isFinal = (head) => /^FINAL\b/.test(head);
|
|
8
|
+
const finalFails = (head) => isFinal(head) && /\bFAIL(ED|S)?\b/.test(head);
|
|
9
|
+
const finalPasses = (head) => isFinal(head) && !FINAL_HOLD.test(head) && (!/^FINAL REVIEW\b/.test(head) || /\b(PASS|APPROVED)\b/.test(head));
|
|
10
|
+
/** Matched as words at the start of the type, with or without a dash after: `STOPPED`, `STOPPED: why`, `STOPPED — why`. */
|
|
11
|
+
const TERMINAL = [
|
|
12
|
+
[/^RELEASE ABORTED\b/, 'stopped'],
|
|
13
|
+
[/^RELEASED\b/, 'passed'],
|
|
14
|
+
[/^STOPPED\b/, 'stopped'],
|
|
15
|
+
[/^HANDED OVER\b/, 'handed-over'],
|
|
16
|
+
[/^DONE\b/, 'done'],
|
|
17
|
+
];
|
|
18
|
+
const RELEASE = /^(RELEASED|RELEASE ABORTED)\b/;
|
|
19
|
+
const REOPENS = /^(READY|KICKOFF|RESUMED|PLAN)\b/;
|
|
20
|
+
/** Posts that do not move the work (channels.mjs CHATTER at 6b2fb0b). */
|
|
21
|
+
const CHATTER = /^(WAITING|STATUS|JOINED|POST|NOTE|ACK(?! COMMITTED)|STOPPING|STOOD DOWN|HEARTBEAT|PROGRESS)/;
|
|
22
|
+
const TEXT = {
|
|
23
|
+
passed: 'Final review passed', failed: 'Final review failed', done: 'Closed: marked done', stopped: 'Closed: stopped', 'handed-over': 'Closed: handed over',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const upper = (s) => String(s || '').toUpperCase().trim();
|
|
27
|
+
const isUser = (p) => upper(p.role) === 'USER';
|
|
28
|
+
const isReviewer = (p) => upper(p.role).includes('REVIEWER');
|
|
29
|
+
|
|
30
|
+
/** What a header says about its author's own work: passed, failed, done, stopped or handed-over; null when it ends nothing. */
|
|
31
|
+
export function roleOutcome(p) {
|
|
32
|
+
if (!p || isUser(p)) return null;
|
|
33
|
+
const head = upper(p.head || p.type);
|
|
34
|
+
if (isReviewer(p) && isFinal(head)) return finalFails(head) ? 'failed' : finalPasses(head) ? 'passed' : null;
|
|
35
|
+
const hit = TERMINAL.find(([re]) => re.test(upper(p.type)));
|
|
36
|
+
return hit ? hit[1] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The outcome one header closes the whole stream with, or null: a reviewer's final pass, a release or its abort, or
|
|
41
|
+
* any terminal header of the stream's ARCHITECT, who owns it. A FINAL: FAIL ends only the reviewer's own turn.
|
|
42
|
+
*/
|
|
43
|
+
export function streamCloser(p) {
|
|
44
|
+
const outcome = roleOutcome(p);
|
|
45
|
+
if (!outcome || outcome === 'failed') return null;
|
|
46
|
+
if (isReviewer(p) && outcome === 'passed') return outcome;
|
|
47
|
+
if (RELEASE.test(upper(p.type))) return outcome;
|
|
48
|
+
return upper(p.role) === 'ARCHITECT' ? outcome : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pulse 1.1.2 (E2, E3): a header that ends its author's turn, so a channel whose newest post is one is not waiting on
|
|
53
|
+
* anyone: FINAL: PASS or FAIL, DONE, STOPPED, HANDED OVER, RELEASED, RELEASE ABORTED, or APPROVED from a reviewer.
|
|
54
|
+
*/
|
|
55
|
+
export function isTerminal(p) {
|
|
56
|
+
if (!p || isUser(p)) return false;
|
|
57
|
+
return Boolean(roleOutcome(p)) || (isReviewer(p) && /^APPROVED\b/.test(upper(p.type)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A READY, KICKOFF, RESUMED or PLAN from an agent reopens a closed stream. */
|
|
61
|
+
export const reopens = (p) => !!p && !isUser(p) && REOPENS.test(upper(p.type));
|
|
62
|
+
|
|
63
|
+
const moves = (p) => !isUser(p) && !CHATTER.test(upper(p.type));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* With no single closer standing: the stream is closed when every role that posted moving headers (USER left out)
|
|
67
|
+
* has a terminal one as its latest, none of them failed, and whoever holds the ball (`ball`, as analyseStream names
|
|
68
|
+
* it: CODER, REVIEWER, ARCHITECT, AUDITOR or YOU) is one of those roles. A coder's DONE hands the ball to an architect
|
|
69
|
+
* who has not posted, so it leaves the stream open. The newest terminal header names the outcome. Posts oldest first.
|
|
70
|
+
*/
|
|
71
|
+
export function everyRoleClosed(posts, ball = null) {
|
|
72
|
+
const latest = new Map();
|
|
73
|
+
for (const p of posts || []) if (moves(p)) latest.set(upper(p.role), p);
|
|
74
|
+
if (!latest.size || ball === 'YOU') return null;
|
|
75
|
+
if (ball && ![...latest.keys()].some((role) => role.includes(ball))) return null;
|
|
76
|
+
const ends = [...latest.values()].map((p) => ({ p, outcome: roleOutcome(p) }));
|
|
77
|
+
if (!ends.every((e) => e.outcome && e.outcome !== 'failed')) return null;
|
|
78
|
+
return ends.reduce((a, b) => ((b.p.at || 0) >= (a.p.at || 0) ? b : a)).outcome;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The words a closed stream's card shows. */
|
|
82
|
+
export const closedText = (outcome) => TEXT[outcome] || TEXT.passed;
|
|
83
|
+
|
|
84
|
+
/** A delivery whose streams are all closed: "Final review passed" when every one passed, else what each ended as. */
|
|
85
|
+
export function closedReason(outcomes) {
|
|
86
|
+
const list = outcomes.map((o) => o || 'passed');
|
|
87
|
+
if (list.every((o) => o === 'passed')) return TEXT.passed;
|
|
88
|
+
const counts = new Map();
|
|
89
|
+
for (const o of list) counts.set(o, (counts.get(o) || 0) + 1);
|
|
90
|
+
const words = { passed: 'passed', done: 'marked done', stopped: 'stopped', 'handed-over': 'handed over' };
|
|
91
|
+
return `Closed: ${[...counts].map(([o, n]) => `${n} ${words[o] || o}`).join(', ')}`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// agents-hub lib/collect-worker.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Runs collect() off the server's event loop, then persists discoveries and returns a pure projection.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { hubHome, runnerDb } from '../home.mjs';
|
|
5
|
+
import { parentPort } from 'node:worker_threads';
|
|
6
|
+
import { collect } from './collect.mjs';
|
|
7
|
+
import { openHub } from '../core/hub.mjs';
|
|
8
|
+
import { discoveryRunner } from '../workers/collector.mjs';
|
|
9
|
+
|
|
10
|
+
const HUB_ROOT = hubHome();
|
|
11
|
+
const hub = openHub(path.join(HUB_ROOT, 'state'), runnerDb());
|
|
12
|
+
const run = discoveryRunner({ hub, collect });
|
|
13
|
+
|
|
14
|
+
parentPort.on('message', ({ id, hours, managerView, aliases, discoveryEpoch }) => {
|
|
15
|
+
try {
|
|
16
|
+
parentPort.postMessage({ id, body: run(hours, managerView, aliases, { discoveryEpoch }) });
|
|
17
|
+
} catch (e) {
|
|
18
|
+
parentPort.postMessage({ id, error: String((e && e.stack) || e) });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
@@ -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
|
+
}
|