@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.
Files changed (101) hide show
  1. package/README.md +46 -0
  2. package/package.json +7 -4
  3. package/src/clients/hub-bridge.mjs +99 -0
  4. package/src/clients/hub.mjs +324 -0
  5. package/src/commands/pulse/cutover.js +39 -0
  6. package/src/commands/pulse/doctor.js +1 -0
  7. package/src/commands/pulse/import.js +62 -0
  8. package/src/commands/pulse/log-write.js +26 -0
  9. package/src/commands/pulse/parity.js +20 -0
  10. package/src/commands/pulse/shadow.js +19 -0
  11. package/src/daemon/main.js +19 -12
  12. package/src/hub/core/auth.mjs +80 -0
  13. package/src/hub/core/changes.mjs +38 -0
  14. package/src/hub/core/conversations.mjs +121 -0
  15. package/src/hub/core/db.mjs +263 -0
  16. package/src/hub/core/edgeIds.mjs +9 -0
  17. package/src/hub/core/hub.mjs +20 -0
  18. package/src/hub/core/ids.mjs +39 -0
  19. package/src/hub/core/leases.mjs +55 -0
  20. package/src/hub/core/messaging.mjs +322 -0
  21. package/src/hub/core/registry.mjs +281 -0
  22. package/src/hub/core/ticks.mjs +58 -0
  23. package/src/hub/core/wake.mjs +125 -0
  24. package/src/hub/home.mjs +16 -0
  25. package/src/hub/hooks/hub-bridge.mjs +167 -0
  26. package/src/hub/keepawake/agent.mjs +96 -0
  27. package/src/hub/lib/brief.mjs +133 -0
  28. package/src/hub/lib/channels.mjs +268 -0
  29. package/src/hub/lib/chat.mjs +163 -0
  30. package/src/hub/lib/closure.mjs +92 -0
  31. package/src/hub/lib/collect-worker.mjs +20 -0
  32. package/src/hub/lib/collect.mjs +342 -0
  33. package/src/hub/lib/deliveries.mjs +222 -0
  34. package/src/hub/lib/git.mjs +93 -0
  35. package/src/hub/lib/me.mjs +123 -0
  36. package/src/hub/lib/personas.mjs +143 -0
  37. package/src/hub/lib/status.mjs +147 -0
  38. package/src/hub/lib/store.mjs +106 -0
  39. package/src/hub/lib/titles.mjs +29 -0
  40. package/src/hub/lib/transcript-feed.mjs +72 -0
  41. package/src/hub/lib/transcripts.mjs +364 -0
  42. package/src/hub/lib/writing.mjs +18 -0
  43. package/src/hub/projections/deliveries.mjs +31 -0
  44. package/src/hub/projections/graph.mjs +149 -0
  45. package/src/hub/runners/drivers.mjs +162 -0
  46. package/src/hub/workers/collector.mjs +128 -0
  47. package/src/hub/workers/manager.mjs +366 -0
  48. package/src/hub/workers/runner.mjs +145 -0
  49. package/src/lib/application.js +82 -34
  50. package/src/lib/channelScanner.js +99 -0
  51. package/src/lib/clientInstaller.js +111 -0
  52. package/src/lib/cloudFirstCommands.js +293 -0
  53. package/src/lib/contract.sha256 +1 -1
  54. package/src/lib/cutover.js +376 -0
  55. package/src/lib/cutoverWiring.js +137 -0
  56. package/src/lib/daemonLock.js +2 -0
  57. package/src/lib/daemonLoop.js +5 -2
  58. package/src/lib/daemonProcess.js +16 -2
  59. package/src/lib/daemonRuntime.js +8 -0
  60. package/src/lib/doctorWarnings.js +53 -0
  61. package/src/lib/errors.js +14 -0
  62. package/src/lib/hubDbReader.js +132 -0
  63. package/src/lib/hubMapping.js +108 -0
  64. package/src/lib/hubScan.js +611 -0
  65. package/src/lib/hubSnapshot.js +64 -0
  66. package/src/lib/mirrorContract.js +1 -1
  67. package/src/lib/mirrorService.js +8 -1
  68. package/src/lib/mirrorSource.js +13 -38
  69. package/src/lib/nodeFloor.js +19 -0
  70. package/src/lib/outboxFlusher.js +75 -0
  71. package/src/lib/pairingLifecycle.js +1 -1
  72. package/src/lib/reconcile.js +316 -0
  73. package/src/lib/reconcileView.js +129 -0
  74. package/src/lib/runnerStore.js +418 -0
  75. package/src/lib/serviceManager.js +61 -0
  76. package/src/lib/shadowService.js +435 -0
  77. package/src/lib/sqlite.js +28 -0
  78. package/src/lib/tokenStore.js +43 -4
  79. package/src/lib/transport.js +53 -5
  80. package/src/lib/transportContract.js +3 -0
  81. package/src/lib/uploadRedactor.js +102 -0
  82. package/src/runner/applier.js +297 -0
  83. package/src/runner/bridge.js +289 -0
  84. package/src/runner/busIdentity.js +499 -0
  85. package/src/runner/capture.js +103 -0
  86. package/src/runner/captureMapping.js +21 -0
  87. package/src/runner/channelDiscipline.js +425 -0
  88. package/src/runner/deliveryStates.js +220 -0
  89. package/src/runner/facade.js +876 -0
  90. package/src/runner/hubCapture.js +160 -0
  91. package/src/runner/lease.js +46 -0
  92. package/src/runner/main.js +35 -0
  93. package/src/runner/render.js +91 -0
  94. package/src/runner/runnerChild.js +57 -0
  95. package/src/runner/runnerHub.js +104 -0
  96. package/src/runner/runnerProcess.js +124 -0
  97. package/src/runner/runnerSchema.js +51 -0
  98. package/src/runner/runnerService.js +140 -0
  99. package/src/runner/spool.js +86 -0
  100. package/src/runner/spoolInto.js +22 -0
  101. package/src/runner/statusPage.js +30 -0
@@ -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
+ }
@@ -0,0 +1,93 @@
1
+ // agents-hub lib/git.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Which branch a checkout is on, and whether it is a worktree of another repo, read from git's own files (no git
3
+ // process): `<dir>/.git` is a directory for a main checkout, or a file `gitdir: <main>/.git/worktrees/<name>` for a
4
+ // worktree. Cached by the HEAD file's mtime.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ const cache = new Map();
9
+ const readSmall = (file) => { try { return fs.readFileSync(file, 'utf8').slice(0, 4096).trim(); } catch { return null; } };
10
+
11
+ /** { path, branch, sha, worktree, mainRepo } for a checkout directory, or null when it is not a git checkout. */
12
+ export function gitInfo(dir) {
13
+ let gitDir = null;
14
+ let worktree = false;
15
+ try {
16
+ const st = fs.lstatSync(path.join(dir, '.git'));
17
+ if (st.isDirectory()) gitDir = path.join(dir, '.git');
18
+ else if (st.isFile()) {
19
+ const m = (readSmall(path.join(dir, '.git')) || '').match(/^gitdir:\s*(.+)$/m);
20
+ if (m) { gitDir = path.resolve(dir, m[1].trim()); worktree = true; }
21
+ }
22
+ } catch { return null; }
23
+ if (!gitDir) return null;
24
+ const headFile = path.join(gitDir, 'HEAD');
25
+ let mtime = 0;
26
+ try { mtime = fs.statSync(headFile).mtimeMs; } catch { return null; }
27
+ const hit = cache.get(dir);
28
+ if (hit && hit.mtime === mtime && hit.gitDir === gitDir) return hit.info;
29
+ const head = readSmall(headFile) || '';
30
+ const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
31
+ const common = worktree ? readSmall(path.join(gitDir, 'commondir')) : null;
32
+ const mainGit = worktree ? path.resolve(gitDir, common || '../..') : gitDir;
33
+ const info = {
34
+ path: dir,
35
+ branch: ref ? ref[1] : null,
36
+ sha: ref ? null : (head.match(/^[0-9a-f]{7,40}$/) || [])[0]?.slice(0, 10) || null,
37
+ worktree,
38
+ mainRepo: worktree ? path.dirname(mainGit) : null,
39
+ };
40
+ if (cache.size > 500) cache.clear();
41
+ cache.set(dir, { mtime, gitDir, info });
42
+ return info;
43
+ }
44
+
45
+ function branchExists(mainGit, branch) {
46
+ if (fs.existsSync(path.join(mainGit, 'refs', 'heads', ...branch.split('/')))) return true;
47
+ return (readSmallBig(path.join(mainGit, 'packed-refs')) || '').includes(` refs/heads/${branch}\n`);
48
+ }
49
+ function readSmallBig(file) { try { return `${fs.readFileSync(file, 'utf8').slice(0, 2e6)}\n`; } catch { return null; } }
50
+
51
+ /** Where a branch is checked out: the main checkout or one of its worktrees (from `.git/worktrees/<n>/{HEAD,gitdir}`). */
52
+ function checkedOutAt(mainGit, branch) {
53
+ const want = `ref: refs/heads/${branch}`;
54
+ if (readSmall(path.join(mainGit, 'HEAD')) === want) return path.dirname(mainGit);
55
+ let names = [];
56
+ try { names = fs.readdirSync(path.join(mainGit, 'worktrees')); } catch { return null; }
57
+ for (const n of names) {
58
+ if (readSmall(path.join(mainGit, 'worktrees', n, 'HEAD')) !== want) continue;
59
+ const gitFile = readSmall(path.join(mainGit, 'worktrees', n, 'gitdir'));
60
+ if (gitFile) return path.dirname(gitFile);
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * A task's branch and worktree: the branch its KICKOFF named (else the one its channel's checkout is on), where that
67
+ * branch is checked out now, and whether the branch still exists.
68
+ */
69
+ export function taskCheckout(checkout, planned) {
70
+ if (!checkout) return null;
71
+ const branch = (planned && planned.branch) || checkout.branch;
72
+ if (!branch) return { branch: null, sha: checkout.sha, worktreePath: checkout.path, isWorktree: checkout.worktree, mainRepo: checkout.mainRepo, channelCheckout: checkout.path, exists: null, here: true };
73
+ const mainGit = path.join(checkout.mainRepo || checkout.path, '.git');
74
+ const at = checkout.branch === branch ? checkout.path : checkedOutAt(mainGit, branch);
75
+ return {
76
+ branch,
77
+ worktreePath: at,
78
+ isWorktree: !!at && at !== (checkout.mainRepo || (checkout.worktree ? null : checkout.path)),
79
+ mainRepo: checkout.mainRepo || checkout.path,
80
+ channelCheckout: checkout.path,
81
+ channelBranch: checkout.branch,
82
+ exists: branchExists(mainGit, branch),
83
+ here: checkout.branch === branch,
84
+ };
85
+ }
86
+
87
+ /** The branch and worktree a KICKOFF post asked for ("Worktree <path>, branch <name>"). */
88
+ export function plannedCheckout(text) {
89
+ const t = String(text || '');
90
+ const branch = (t.match(/\bbranch\s+`?([A-Za-z0-9._/-]+?)`?(?=[\s,.;)]|$)/i) || [])[1] || null;
91
+ const wt = (t.match(/\bworktree\s+`?([^\s,`]+)`?/i) || [])[1] || null;
92
+ return branch || wt ? { branch, worktree: wt ? wt.replace(/[).,]+$/, '') : null } : null;
93
+ }
@@ -0,0 +1,123 @@
1
+ // agents-hub lib/me.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Who "@you" is: the AgentPulse CLI's signed-in user. The CLI config holds no name, so the server asks the platform's
3
+ // OIDC userinfo with the CLI's access token (as `agentpulse login` does) and keeps the answer in memory. The browser
4
+ // only ever gets { name, handle, aliases }; the token goes only to the profile's own platform and is never logged.
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { USER_ALIAS } from './channels.mjs';
9
+
10
+ export const NOBODY = Object.freeze({ name: 'You', handle: null, aliases: Object.freeze([]) });
11
+ /** Why the name is "You". Only these words are ever logged or kept, never an error message or a header. */
12
+ export const REASONS = Object.freeze(['no-config', 'unauthorized', 'timeout', 'network', 'blank']);
13
+ const TIMEOUT_MS = 3000;
14
+ const FRESH_MS = 3600e3;
15
+ const RETRY_MS = 5 * 60e3;
16
+ const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]']);
17
+
18
+ export const cliConfigPath = (env = process.env, home = os.homedir()) => path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'agentpulse', 'config.json');
19
+
20
+ /** The CLI's active profile: its default, else its only one. */
21
+ export function activeProfile(cfg) {
22
+ const profiles = (cfg && typeof cfg.profiles === 'object' && cfg.profiles) || {};
23
+ if (cfg && cfg.defaultProfile && profiles[cfg.defaultProfile]) return profiles[cfg.defaultProfile];
24
+ const names = Object.keys(profiles);
25
+ return names.length === 1 ? profiles[names[0]] : null;
26
+ }
27
+
28
+ /** The profile's platform origin, when the bearer may go there: https, or http on this machine. */
29
+ export function platformOrigin(baseUrl) {
30
+ let u;
31
+ try { u = new URL(String(baseUrl)); } catch { return null; }
32
+ return u.protocol === 'https:' || (u.protocol === 'http:' && LOCAL.has(u.hostname)) ? u.origin : null;
33
+ }
34
+
35
+ const tidy = (s) => (typeof s === 'string' ? s.replace(/\s+/g, ' ').trim().slice(0, 60) : '');
36
+ const localPart = (s) => tidy(s).split('@')[0];
37
+
38
+ /**
39
+ * OIDC claims or a profile โ†’ { name, handle, aliases }: "First Last", else `name`, else the username; NOBODY when blank.
40
+ * Aliases are the handle and the name without spaces.
41
+ */
42
+ export function identityOf(u) {
43
+ if (!u || typeof u !== 'object') return NOBODY;
44
+ const first = tidy(u.given_name || u.firstName || u.first_name);
45
+ const last = tidy(u.family_name || u.lastName || u.last_name);
46
+ const username = localPart(u.preferred_username || u.username || u.email);
47
+ const name = tidy([first, last].filter(Boolean).join(' ')) || tidy(u.name) || username;
48
+ if (!name) return NOBODY;
49
+ const handle = USER_ALIAS.test(username) ? username : null;
50
+ const aliases = [...new Set([handle, name.replace(/\s+/g, '')]
51
+ .filter((a) => a && USER_ALIAS.test(a) && !/^(you|user)$/i.test(a)).map((a) => a.toLowerCase()))];
52
+ return { name, handle, aliases };
53
+ }
54
+
55
+ /** The aliases no agent persona has: when the user's handle is also an agent's username, "@" it means the agent. */
56
+ export const aliasesFor = (me, taken) => me.aliases.filter((a) => !taken.has(a.toLowerCase()));
57
+
58
+ async function lookup(configPath, fetchImpl) {
59
+ let cfg;
60
+ try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return { reason: 'no-config' }; }
61
+ const profile = activeProfile(cfg);
62
+ if (!profile) return { reason: 'no-config' };
63
+ const own = identityOf(profile);
64
+ if (own !== NOBODY) return { me: own };
65
+ const origin = platformOrigin(profile.baseUrl);
66
+ if (!origin || typeof profile.accessToken !== 'string' || !profile.accessToken) return { reason: 'no-config' };
67
+ const ctl = new AbortController();
68
+ const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
69
+ try {
70
+ const r = await fetchImpl(`${origin}/api/oauth/userinfo`, {
71
+ headers: { authorization: `Bearer ${profile.accessToken}`, accept: 'application/json' }, redirect: 'error', signal: ctl.signal,
72
+ });
73
+ if (r.status === 401 || r.status === 403) return { reason: 'unauthorized' };
74
+ if (!r.ok) return { reason: 'network' };
75
+ const got = identityOf(await r.json().catch(() => null));
76
+ return got === NOBODY ? { reason: 'blank' } : { me: got };
77
+ } catch {
78
+ return { reason: ctl.signal.aborted ? 'timeout' : 'network' };
79
+ } finally {
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The CLI user, resolved in the background: `current()` never waits, and starts a lookup when the answer is stale (hourly,
86
+ * or every few minutes while it is still "You"). A failed lookup keeps the last good answer; `listen(fn)` hears changes,
87
+ * and `reason()` says why the name is "You".
88
+ */
89
+ export function cliIdentity({ configPath = cliConfigPath(), fetchImpl = globalThis.fetch, now = Date.now, log = console.error } = {}) {
90
+ let me = NOBODY;
91
+ let why = null;
92
+ let checkedAt = -Infinity;
93
+ let running = null;
94
+ const listeners = new Set();
95
+ function refresh() {
96
+ if (running) return running;
97
+ checkedAt = now();
98
+ running = lookup(configPath, fetchImpl).then((got) => {
99
+ running = null;
100
+ if (got.reason) {
101
+ if (me === NOBODY && got.reason !== why) log(`@you shows "You": the CLI user could not be read (${got.reason})`);
102
+ why = got.reason;
103
+ } else {
104
+ why = null;
105
+ if (got.me.name !== me.name || got.me.aliases.join('|') !== me.aliases.join('|')) {
106
+ me = got.me;
107
+ for (const fn of listeners) fn(me);
108
+ }
109
+ }
110
+ return me;
111
+ });
112
+ return running;
113
+ }
114
+ return {
115
+ current() {
116
+ if (now() - checkedAt >= (me === NOBODY ? RETRY_MS : FRESH_MS)) refresh();
117
+ return me;
118
+ },
119
+ refresh,
120
+ reason: () => why,
121
+ listen(fn) { listeners.add(fn); },
122
+ };
123
+ }
@@ -0,0 +1,143 @@
1
+ // agents-hub lib/personas.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
2
+ // Every agent gets a persona the first time the hub sees it: a first name, a surname, a username that is never reused,
3
+ // and a seed for its illustrated face. The pairing is stored, so an agent keeps its identity for good.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { HUB } from './store.mjs';
7
+
8
+ const FILE = path.join(HUB, 'state', 'personas.json');
9
+ const RECENT_MS = 7 * 24 * 3600e3;
10
+ const FIRST = [
11
+ 'Maya', 'Arjun', 'Lena', 'Kofi', 'Sofia', 'Ravi', 'Noor', 'Mateo', 'Aiko', 'Elias', 'Priya', 'Tomas', 'Zara', 'Kenji', 'Amara', 'Luca',
12
+ 'Ines', 'Dev', 'Hana', 'Omar', 'Clara', 'Jin', 'Asha', 'Felix', 'Leila', 'Nikhil', 'Mira', 'Diego', 'Yara', 'Sami', 'Ingrid', 'Tariq',
13
+ 'Nora', 'Kai', 'Ananya', 'Bruno', 'Selin', 'Ade', 'Freya', 'Hugo', 'Meera', 'Rafael', 'Suki', 'Idris', 'Lina', 'Oskar', 'Tara', 'Malik',
14
+ 'Esme', 'Rohan', 'Ayla', 'Theo', 'Kavya', 'Pablo', 'Rin', 'Yusuf', 'Alma', 'Ishaan', 'Greta', 'Emeka', 'Lea', 'Vikram', 'Saoirse', 'Ren',
15
+ ];
16
+ const LAST = [
17
+ 'Okafor', 'Sharma', 'Lindqvist', 'Mensah', 'Moreno', 'Iyer', 'Haddad', 'Silva', 'Tanaka', 'Novak', 'Reddy', 'Kowalski', 'Ahmed', 'Sato',
18
+ 'Adeyemi', 'Rossi', 'Costa', 'Patel', 'Kim', 'Farouk', 'Weber', 'Chen', 'Nair', 'Fischer', 'Rahimi', 'Menon', 'Aydin', 'Garcia', 'Nkosi',
19
+ 'Hassan', 'Berg', 'Qureshi', 'Larsen', 'Mori', 'Kapoor', 'Duarte', 'Yilmaz', 'Balogun', 'Olsen', 'Laurent', 'Pillai', 'Ortega', 'Ito',
20
+ 'Abara', 'Vidal', 'Lund', 'Desai', 'Karimi', 'Brandt', 'Rao', 'Demir', 'Herrera', 'Watanabe', 'Eze', 'Nyberg', 'Joshi', 'Santos', 'Park',
21
+ ];
22
+
23
+ /**
24
+ * A personality shapes how an agent talks to you in the hub: temperament, how it writes, and the emoji it reaches for.
25
+ * It is tone only. The role's duties are restated next to it every time, so a reviewer stays exacting and a coder
26
+ * stays precise, whatever its temperament.
27
+ */
28
+ const TEMPERAMENT = ['warm and encouraging', 'dry and witty', 'calm and methodical', 'upbeat and energetic', 'thoughtful and curious',
29
+ 'direct and no-nonsense', 'playful but sharp', 'quietly confident', 'friendly and plain-spoken', 'meticulous and a little wry'];
30
+ const VOICE = ['writes short sentences and gets to the point', 'explains the why in one line', 'likes a quick checklist', 'asks one sharp question back when something is unclear',
31
+ 'sums up first, then adds a light remark', 'speaks plainly, like a colleague at the next desk', 'leads with the answer and keeps the detail for when you ask'];
32
+ const EMOJI_SETS = [['๐Ÿ‘', 'โœ…', '๐Ÿš€'], ['๐Ÿ™‚', '๐Ÿ™Œ', 'โœจ'], ['๐Ÿ‘€', '๐Ÿค”', '๐Ÿ“'], ['๐Ÿ”ฅ', '๐Ÿ’ช', '๐ŸŽ‰'], ['โœ…', '๐Ÿงช', '๐Ÿ”'], ['๐Ÿ˜„', '๐ŸŽ‰', 'โ˜•'], ['๐Ÿ‘Œ', '๐Ÿ› ๏ธ', '๐Ÿ“Œ'], ['๐Ÿ™', '๐Ÿ’ก', '๐ŸŒฑ']];
33
+ const EMOJI_HABIT = ['rarely uses emoji', 'uses an emoji now and then', 'often answers with just an emoji when no words are needed'];
34
+ const ROLE_DUTY = {
35
+ CODER: 'the coder: precise about what you built, tested and committed, and you never claim a check you did not run',
36
+ REVIEWER: 'the reviewer: exacting and honest; personality never softens a finding, a FAIL or a missing check',
37
+ ARCHITECT: 'the architect: you own the plan, the deploys and the evidence, and you keep every stream to its gates',
38
+ AUDITOR: 'the platform auditor: strict about alignment with the platform, and you flag drift plainly',
39
+ MANAGER: 'the hub manager: you coordinate across deliveries, deduplicate shared work and preserve every specialist role and gate',
40
+ };
41
+
42
+ function personalityFor(id) {
43
+ return {
44
+ temperament: TEMPERAMENT[hash(`temper:${id}`) % TEMPERAMENT.length],
45
+ voice: VOICE[hash(`voice:${id}`) % VOICE.length],
46
+ emoji: EMOJI_SETS[hash(`emoji:${id}`) % EMOJI_SETS.length],
47
+ habit: EMOJI_HABIT[hash(`habit:${id}`) % EMOJI_HABIT.length],
48
+ };
49
+ }
50
+
51
+ /** How an agent should sound when it answers you in the hub, with its role's duties restated so tone never changes the work. */
52
+ export function voiceFor(id, role) {
53
+ let p = null;
54
+ try { p = (JSON.parse(fs.readFileSync(FILE, 'utf8')) || {})[id] || null; } catch { p = null; }
55
+ if (!p) return null;
56
+ const k = p.personality || personalityFor(id);
57
+ const r = String(role || p.role || '').toUpperCase().replace(/[^A-Z]/g, '');
58
+ const duty = ROLE_DUTY[r] || `the ${r ? r.toLowerCase() : 'agent'} on this task, doing that job exactly as before`;
59
+ return `In the hub you are ${p.name} (@${p.username}). Personality: ${k.temperament}; ${k.voice}; ${k.habit} (favourites: ${k.emoji.join(' ')}). `
60
+ + 'Answer like a human teammate with that personality: words, an emoji reaction, or both, whatever a person like you would send. '
61
+ + `It is tone only. You are still ${duty}. It changes nothing in your work or your standards. `
62
+ + 'Whatever the tone, keep the structure readable: a clear first sentence, short paragraphs, bullets, code blocks and tables where they help.';
63
+ }
64
+
65
+ let cache = null;
66
+ let dirty = false;
67
+
68
+ function hash(s) {
69
+ let x = 2166136261;
70
+ for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = Math.imul(x, 16777619); }
71
+ return x >>> 0;
72
+ }
73
+ const slug = (s) => s.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '');
74
+
75
+ /** A username nobody has had: first.last, then first.last2, first.last3โ€ฆ */
76
+ function uniqueUsername(first, last, all) {
77
+ const used = new Set(Object.values(all).map((p) => p.username).filter(Boolean));
78
+ const base = `${slug(first)}.${slug(last)}`;
79
+ if (!used.has(base)) return base;
80
+ let n = 2;
81
+ while (used.has(`${base}${n}`)) n++;
82
+ return `${base}${n}`;
83
+ }
84
+
85
+ /** Older personas had a first name only: give them a surname and a username, keeping name and face. */
86
+ function upgrade(all) {
87
+ for (const [id, p] of Object.entries(all)) {
88
+ if (p && !p.personality) { p.personality = personalityFor(id); dirty = true; }
89
+ if (!p || p.username) continue;
90
+ const first = String(p.name || FIRST[hash(id) % FIRST.length]).split(' ')[0];
91
+ const last = LAST[hash(`last:${id}`) % LAST.length];
92
+ Object.assign(p, { first, last, name: `${first} ${last}`, username: uniqueUsername(first, last, all) });
93
+ dirty = true;
94
+ }
95
+ }
96
+
97
+ function load() {
98
+ if (cache) return cache;
99
+ try { cache = JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch { cache = {}; }
100
+ upgrade(cache);
101
+ return cache;
102
+ }
103
+
104
+ /** The persona of an agent id, creating it on first sight. First names are not reused among recent agents. */
105
+ export function personaFor(id, { role = null, channel = null, now = Date.now() } = {}) {
106
+ const all = load();
107
+ if (all[id]) return all[id];
108
+ const recent = new Set(Object.values(all).filter((p) => now - p.createdAt < RECENT_MS).map((p) => p.first));
109
+ const start = hash(id) % FIRST.length;
110
+ let first = FIRST[start];
111
+ for (let i = 0; i < FIRST.length; i++) if (!recent.has(FIRST[(start + i) % FIRST.length])) { first = FIRST[(start + i) % FIRST.length]; break; }
112
+ const last = LAST[hash(`last:${id}`) % LAST.length];
113
+ all[id] = { first, last, name: `${first} ${last}`, username: uniqueUsername(first, last, all), seed: hash(`face:${id}`), role, channel, createdAt: now, personality: personalityFor(id) };
114
+ dirty = true;
115
+ return all[id];
116
+ }
117
+
118
+ /** Write new or upgraded personas to disk (once per pass). */
119
+ export function savePersonas() {
120
+ if (!dirty || !cache) return;
121
+ fs.writeFileSync(FILE, JSON.stringify(cache, null, 2));
122
+ dirty = false;
123
+ }
124
+
125
+ let usernames = { mtime: null, set: new Set() };
126
+ /** Every username an agent persona has had, re-read when the file changes (the collector writes it in its own thread). */
127
+ export function personaUsernames() {
128
+ let mtime;
129
+ try { mtime = fs.statSync(FILE).mtimeMs; } catch { return new Set(); }
130
+ if (mtime !== usernames.mtime) {
131
+ let all = {};
132
+ try { all = JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch { /* half-written: keep the last set */ return usernames.set; }
133
+ usernames = { mtime, set: new Set(Object.values(all).map((p) => p && typeof p.username === 'string' && p.username.toLowerCase()).filter(Boolean)) };
134
+ }
135
+ return usernames.set;
136
+ }
137
+
138
+ /** Personas created since sinceMs, for "joined" lines in project channels. Read fresh: the collector writes them in its own thread. */
139
+ export function personasSince(sinceMs) {
140
+ let all = {};
141
+ try { all = JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch { return []; }
142
+ return Object.entries(all).filter(([, p]) => p && p.createdAt >= sinceMs).map(([id, p]) => ({ id, ...p }));
143
+ }