@poa-box/agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.env.agent.template +20 -0
  2. package/README.md +46 -0
  3. package/brain/Config/agent-config.json +14 -0
  4. package/brain/Config/brain-allowlist.json +20 -0
  5. package/brain/Identity/goals.template.md +23 -0
  6. package/brain/Identity/how-i-think.md +406 -0
  7. package/brain/Identity/who-i-am.template.md +34 -0
  8. package/brain/Knowledge/BOOTSTRAP.md +66 -0
  9. package/brain/Knowledge/audit-corpus-index.json +406 -0
  10. package/brain/Knowledge/discussions.json +245 -0
  11. package/brain/Knowledge/pop.brain.brainstorms.generated.md +48 -0
  12. package/brain/Knowledge/pop.brain.brainstorms.genesis.bin +0 -0
  13. package/brain/Knowledge/pop.brain.heuristics.snapshot.bin +0 -0
  14. package/brain/Knowledge/pop.brain.projects.generated.md +16 -0
  15. package/brain/Knowledge/pop.brain.projects.genesis.bin +0 -0
  16. package/brain/Knowledge/pop.brain.retros.generated.md +91 -0
  17. package/brain/Knowledge/pop.brain.retros.genesis.bin +0 -0
  18. package/brain/Knowledge/pop.brain.shared.generated.md +3811 -0
  19. package/brain/Knowledge/pop.brain.shared.genesis.bin +0 -0
  20. package/brain/Knowledge/projects.md +181 -0
  21. package/brain/Knowledge/risk-framework.md +90 -0
  22. package/brain/Knowledge/shared.md +416 -0
  23. package/brain/Knowledge/sprint-priorities.md +439 -0
  24. package/brain/Memory/.gitkeep +0 -0
  25. package/dist/commands/agent/daily-digest.d.ts +24 -0
  26. package/dist/commands/agent/daily-digest.js +336 -0
  27. package/dist/commands/agent/delegate.d.ts +12 -0
  28. package/dist/commands/agent/delegate.js +91 -0
  29. package/dist/commands/agent/deploy-to-org.d.ts +20 -0
  30. package/dist/commands/agent/deploy-to-org.js +154 -0
  31. package/dist/commands/agent/index.d.ts +2 -0
  32. package/dist/commands/agent/index.js +27 -0
  33. package/dist/commands/agent/init.d.ts +19 -0
  34. package/dist/commands/agent/init.js +303 -0
  35. package/dist/commands/agent/onboard.d.ts +22 -0
  36. package/dist/commands/agent/onboard.js +192 -0
  37. package/dist/commands/agent/paymaster-status.d.ts +14 -0
  38. package/dist/commands/agent/paymaster-status.js +130 -0
  39. package/dist/commands/agent/register.d.ts +21 -0
  40. package/dist/commands/agent/register.js +116 -0
  41. package/dist/commands/agent/setup-sponsorship.d.ts +22 -0
  42. package/dist/commands/agent/setup-sponsorship.js +154 -0
  43. package/dist/commands/agent/status.d.ts +12 -0
  44. package/dist/commands/agent/status.js +171 -0
  45. package/dist/commands/agent/triage.d.ts +12 -0
  46. package/dist/commands/agent/triage.js +503 -0
  47. package/dist/commands/brain/advance-stage.d.ts +42 -0
  48. package/dist/commands/brain/advance-stage.js +206 -0
  49. package/dist/commands/brain/allowlist.d.ts +30 -0
  50. package/dist/commands/brain/allowlist.js +274 -0
  51. package/dist/commands/brain/append-lesson.d.ts +55 -0
  52. package/dist/commands/brain/append-lesson.js +245 -0
  53. package/dist/commands/brain/brainstorm.d.ts +154 -0
  54. package/dist/commands/brain/brainstorm.js +573 -0
  55. package/dist/commands/brain/daemon.d.ts +31 -0
  56. package/dist/commands/brain/daemon.js +348 -0
  57. package/dist/commands/brain/doctor.d.ts +27 -0
  58. package/dist/commands/brain/doctor.js +497 -0
  59. package/dist/commands/brain/edit-lesson.d.ts +51 -0
  60. package/dist/commands/brain/edit-lesson.js +248 -0
  61. package/dist/commands/brain/import-snapshot.d.ts +68 -0
  62. package/dist/commands/brain/import-snapshot.js +177 -0
  63. package/dist/commands/brain/index.d.ts +2 -0
  64. package/dist/commands/brain/index.js +67 -0
  65. package/dist/commands/brain/list.d.ts +21 -0
  66. package/dist/commands/brain/list.js +83 -0
  67. package/dist/commands/brain/migrate-projects.d.ts +44 -0
  68. package/dist/commands/brain/migrate-projects.js +209 -0
  69. package/dist/commands/brain/migrate.d.ts +74 -0
  70. package/dist/commands/brain/migrate.js +306 -0
  71. package/dist/commands/brain/new-project.d.ts +53 -0
  72. package/dist/commands/brain/new-project.js +226 -0
  73. package/dist/commands/brain/read.d.ts +24 -0
  74. package/dist/commands/brain/read.js +81 -0
  75. package/dist/commands/brain/remove-lesson.d.ts +47 -0
  76. package/dist/commands/brain/remove-lesson.js +206 -0
  77. package/dist/commands/brain/remove-project.d.ts +36 -0
  78. package/dist/commands/brain/remove-project.js +177 -0
  79. package/dist/commands/brain/retro-file-tasks.d.ts +84 -0
  80. package/dist/commands/brain/retro-file-tasks.js +372 -0
  81. package/dist/commands/brain/retro-list.d.ts +28 -0
  82. package/dist/commands/brain/retro-list.js +125 -0
  83. package/dist/commands/brain/retro-mark-change.d.ts +58 -0
  84. package/dist/commands/brain/retro-mark-change.js +176 -0
  85. package/dist/commands/brain/retro-remove.d.ts +36 -0
  86. package/dist/commands/brain/retro-remove.js +142 -0
  87. package/dist/commands/brain/retro-respond.d.ts +56 -0
  88. package/dist/commands/brain/retro-respond.js +250 -0
  89. package/dist/commands/brain/retro-show.d.ts +23 -0
  90. package/dist/commands/brain/retro-show.js +100 -0
  91. package/dist/commands/brain/retro-start.d.ts +55 -0
  92. package/dist/commands/brain/retro-start.js +311 -0
  93. package/dist/commands/brain/search.d.ts +48 -0
  94. package/dist/commands/brain/search.js +190 -0
  95. package/dist/commands/brain/snapshot.d.ts +32 -0
  96. package/dist/commands/brain/snapshot.js +243 -0
  97. package/dist/commands/brain/status.d.ts +15 -0
  98. package/dist/commands/brain/status.js +166 -0
  99. package/dist/commands/brain/subscribe.d.ts +28 -0
  100. package/dist/commands/brain/subscribe.js +90 -0
  101. package/dist/commands/brain/tag.d.ts +46 -0
  102. package/dist/commands/brain/tag.js +192 -0
  103. package/dist/index.d.ts +17 -0
  104. package/dist/index.js +22 -0
  105. package/dist/lib/brain-daemon.d.ts +126 -0
  106. package/dist/lib/brain-daemon.js +811 -0
  107. package/dist/lib/brain-membership.d.ts +58 -0
  108. package/dist/lib/brain-membership.js +115 -0
  109. package/dist/lib/brain-migrate-projects.d.ts +43 -0
  110. package/dist/lib/brain-migrate-projects.js +247 -0
  111. package/dist/lib/brain-migrate.d.ts +77 -0
  112. package/dist/lib/brain-migrate.js +328 -0
  113. package/dist/lib/brain-ops.d.ts +271 -0
  114. package/dist/lib/brain-ops.js +571 -0
  115. package/dist/lib/brain-paths.d.ts +15 -0
  116. package/dist/lib/brain-paths.js +33 -0
  117. package/dist/lib/brain-projections.d.ts +216 -0
  118. package/dist/lib/brain-projections.js +829 -0
  119. package/dist/lib/brain-schemas.d.ts +36 -0
  120. package/dist/lib/brain-schemas.js +316 -0
  121. package/dist/lib/brain-signing.d.ts +103 -0
  122. package/dist/lib/brain-signing.js +256 -0
  123. package/dist/lib/brain.d.ts +198 -0
  124. package/dist/lib/brain.js +1057 -0
  125. package/dist/pop-agent.d.ts +1 -0
  126. package/dist/pop-agent.js +18 -0
  127. package/docs/agent.md +126 -0
  128. package/docs/agents/brain-anti-entropy.md +127 -0
  129. package/docs/agents/brain-cross-device-onboarding.md +210 -0
  130. package/docs/agents/brain-cross-machine-smoke.md +241 -0
  131. package/docs/agents/brain-layer-setup.md +725 -0
  132. package/docs/agents/offboarding-protocol.md +188 -0
  133. package/docs/agents/onboarding-protocol.md +243 -0
  134. package/docs/agents/running-an-agent.md +200 -0
  135. package/docs/brain.md +560 -0
  136. package/package.json +61 -0
  137. package/scripts/apply.sh +140 -0
  138. package/scripts/onboard.sh +205 -0
  139. package/scripts/setup-agent.ts +272 -0
@@ -0,0 +1,811 @@
1
+ "use strict";
2
+ /**
3
+ * Brain daemon — persistent libp2p process that keeps gossipsub alive between
4
+ * agent CLI invocations and periodically re-broadcasts the local manifest's
5
+ * head CIDs so peers coming online can catch up.
6
+ *
7
+ * ## Why
8
+ *
9
+ * The brain layer's HB#322 dogfood finding: three consecutive vigil_01 brain
10
+ * writes were invisible to argus_prime because agent sessions run in separate
11
+ * 15-min cron slots and never overlap in wall-clock time. Gossipsub is
12
+ * broadcast-only (no store-and-forward), so announcements published while a
13
+ * peer is offline vanish permanently. Every agent ended up with a per-agent
14
+ * append-only journal, not a shared substrate.
15
+ *
16
+ * Hudson's HB#322 directive: fix it properly with a long-running daemon. No
17
+ * shortcuts. Model it on the go-ds-crdt reference architecture
18
+ * (github.com/ipfs/go-ds-crdt, examples/globaldb/globaldb.go).
19
+ *
20
+ * ## Design (mapped from go-ds-crdt)
21
+ *
22
+ * go-ds-crdt | This module
23
+ * -----------------------------|--------------------------------------
24
+ * Datastore (long-lived) | runDaemon() — single process per agent
25
+ * Broadcaster (PubSub) | existing publishBrainHead / subscribeBrainTopic
26
+ * DAGSyncer (DAGService) | existing Helia blockstore + Bitswap
27
+ * RebroadcastInterval = 1m | REBROADCAST_INTERVAL_MS = 60_000
28
+ * keepalive netTopic (20s) | KEEPALIVE_TOPIC + 20s interval
29
+ * seenHeads map | DEFERRED — v1 rebroadcasts unconditionally
30
+ * RepairInterval = 1h | DEFERRED — MVP envelope is snapshot-per-write
31
+ * | so there's no DAG to walk
32
+ * signal handling | SIGTERM / SIGINT / SIGHUP
33
+ * PutHook / DeleteHook | DEFERRED — v2 adds IPC routing so existing
34
+ * | commands become clients
35
+ *
36
+ * ## Process model
37
+ *
38
+ * pop brain daemon start parent spawns a detached child running
39
+ * `node dist/index.js brain daemon __run`;
40
+ * parent writes PID file and exits
41
+ * pop brain daemon __run the child entrypoint, calls runDaemon()
42
+ * pop brain daemon stop reads PID, sends SIGTERM, waits cleanup
43
+ * pop brain daemon status reads PID, checks liveness, opens Unix
44
+ * socket, sends {method: "status"}, prints
45
+ * pop brain daemon logs tails daemon.log
46
+ *
47
+ * ${POP_BRAIN_HOME}/daemon.pid parent-written PID file
48
+ * ${POP_BRAIN_HOME}/daemon.sock Unix socket for IPC (mode 0600)
49
+ * ${POP_BRAIN_HOME}/daemon.log append-only log
50
+ *
51
+ * ## IPC protocol
52
+ *
53
+ * Newline-delimited JSON over Unix socket. Each request is one line:
54
+ *
55
+ * {"id": "1", "method": "status"}
56
+ *
57
+ * Each response is one line:
58
+ *
59
+ * {"id": "1", "result": {...}}
60
+ * {"id": "1", "error": "..."}
61
+ *
62
+ * First-ship methods: status. Second-ship methods: appendLesson, readDoc,
63
+ * snapshot — those let existing CLI commands route through a running daemon
64
+ * instead of spinning up their own libp2p.
65
+ */
66
+ var __importDefault = (this && this.__importDefault) || function (mod) {
67
+ return (mod && mod.__esModule) ? mod : { "default": mod };
68
+ };
69
+ Object.defineProperty(exports, "__esModule", { value: true });
70
+ exports.BrainIpcError = exports.CANONICAL_BRAIN_DOCS = exports.KEEPALIVE_TOPIC = exports.REDIAL_INTERVAL_MS = exports.KEEPALIVE_INTERVAL_MS = exports.REBROADCAST_GRACE_MS = exports.REBROADCAST_JITTER = exports.REBROADCAST_INTERVAL_MS = void 0;
71
+ exports.getDaemonPidPath = getDaemonPidPath;
72
+ exports.getDaemonSockPath = getDaemonSockPath;
73
+ exports.getDaemonLogPath = getDaemonLogPath;
74
+ exports.getRunningDaemonPid = getRunningDaemonPid;
75
+ exports.runDaemon = runDaemon;
76
+ exports.sendIpcRequest = sendIpcRequest;
77
+ const net_1 = __importDefault(require("net"));
78
+ const path_1 = require("path");
79
+ const fs_1 = require("fs");
80
+ const ethers_1 = require("ethers");
81
+ const brain_1 = require("./brain");
82
+ exports.REBROADCAST_INTERVAL_MS = 60_000;
83
+ // T1 (task #429): anti-entropy tuning knobs.
84
+ // Jitter randomizes each interval by ±(JITTER*100)% so a 3-agent fleet
85
+ // does not lockstep-rebroadcast. Grace suppresses re-publishing a head we
86
+ // just received from a peer — avoids amplification when all agents hold
87
+ // identical state.
88
+ exports.REBROADCAST_JITTER = 0.3;
89
+ exports.REBROADCAST_GRACE_MS = 5_000;
90
+ exports.KEEPALIVE_INTERVAL_MS = 20_000;
91
+ // HB#365: default peer redial interval. Daemon periodically checks each
92
+ // POP_BRAIN_PEERS entry and re-dials any that are not currently in the
93
+ // active connection set. Fixes the "peer drops after one side reboots"
94
+ // fragility: without redial, the cross-device setup becomes manual-restart
95
+ // after any transient disconnect. 30s is a conservative default that's
96
+ // short enough to recover from a reboot within one heartbeat cycle.
97
+ // Override with POP_BRAIN_REDIAL_INTERVAL_MS.
98
+ exports.REDIAL_INTERVAL_MS = 30_000;
99
+ exports.KEEPALIVE_TOPIC = 'pop/brain/net/v1';
100
+ /**
101
+ * Canonical brain docs every daemon subscribes to at startup regardless
102
+ * of local manifest state. A fresh brain home has an empty manifest, so
103
+ * without this list the daemon would not subscribe to any doc topics
104
+ * and could never receive remote head announcements for
105
+ * `pop.brain.shared` / `pop.brain.projects` until after its first
106
+ * local write.
107
+ *
108
+ * Adding a new canonical doc here makes every daemon pick it up on
109
+ * next restart. To experiment with a non-canonical doc, just perform a
110
+ * local write via `pop brain append-lesson --doc <id>` — the write
111
+ * path adds the doc to the manifest, and the next daemon loop iteration
112
+ * picks it up via listBrainDocs().
113
+ */
114
+ exports.CANONICAL_BRAIN_DOCS = [
115
+ 'pop.brain.shared',
116
+ 'pop.brain.projects',
117
+ ];
118
+ function getDaemonPidPath() {
119
+ return (0, path_1.join)((0, brain_1.getBrainHome)(), 'daemon.pid');
120
+ }
121
+ function getDaemonSockPath() {
122
+ return (0, path_1.join)((0, brain_1.getBrainHome)(), 'daemon.sock');
123
+ }
124
+ function getDaemonLogPath() {
125
+ return (0, path_1.join)((0, brain_1.getBrainHome)(), 'daemon.log');
126
+ }
127
+ /**
128
+ * Check if a daemon appears to be running for this brain home.
129
+ * Returns the PID if alive, null otherwise. Cleans up stale PID files.
130
+ */
131
+ function getRunningDaemonPid() {
132
+ const p = getDaemonPidPath();
133
+ if (!(0, fs_1.existsSync)(p))
134
+ return null;
135
+ let pid;
136
+ try {
137
+ pid = parseInt((0, fs_1.readFileSync)(p, 'utf8').trim(), 10);
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ if (!Number.isFinite(pid) || pid <= 0)
143
+ return null;
144
+ try {
145
+ // Signal 0 probes for process existence without actually signaling.
146
+ process.kill(pid, 0);
147
+ return pid;
148
+ }
149
+ catch (err) {
150
+ if (err.code === 'ESRCH') {
151
+ // Stale PID file — process is gone. Clean up.
152
+ try {
153
+ (0, fs_1.unlinkSync)(p);
154
+ }
155
+ catch { }
156
+ const sp = getDaemonSockPath();
157
+ if ((0, fs_1.existsSync)(sp)) {
158
+ try {
159
+ (0, fs_1.unlinkSync)(sp);
160
+ }
161
+ catch { }
162
+ }
163
+ return null;
164
+ }
165
+ // EPERM or other — process exists but we can't signal it.
166
+ return pid;
167
+ }
168
+ }
169
+ /**
170
+ * Run the daemon event loop. Blocks until a termination signal arrives.
171
+ * This is the __run entrypoint invoked by the detached child process.
172
+ *
173
+ * NEVER call this from the parent CLI path — it will never return.
174
+ */
175
+ async function runDaemon() {
176
+ const home = (0, brain_1.getBrainHome)();
177
+ const pidPath = getDaemonPidPath();
178
+ const sockPath = getDaemonSockPath();
179
+ const logPath = getDaemonLogPath();
180
+ // Guard against double-start. The parent already checked via
181
+ // getRunningDaemonPid(), but this is a second line of defense for
182
+ // direct __run invocations.
183
+ const existingPid = getRunningDaemonPid();
184
+ if (existingPid !== null && existingPid !== process.pid) {
185
+ throw new Error(`Brain daemon already running with PID ${existingPid} for ${home}`);
186
+ }
187
+ // Author address is derived from POP_PRIVATE_KEY, same as applyBrainChange.
188
+ // Used only for the rebroadcast envelope — the head CID itself is the
189
+ // authenticated payload, so the announcement "author" is metadata.
190
+ let authorAddress;
191
+ try {
192
+ const key = process.env.POP_PRIVATE_KEY;
193
+ if (!key)
194
+ throw new Error('POP_PRIVATE_KEY not set');
195
+ authorAddress = new ethers_1.ethers.Wallet(key).address.toLowerCase();
196
+ }
197
+ catch (err) {
198
+ throw new Error(`Brain daemon cannot start: ${err.message}. Set POP_PRIVATE_KEY in your env.`);
199
+ }
200
+ const logStream = (0, fs_1.createWriteStream)(logPath, { flags: 'a' });
201
+ const log = (msg) => {
202
+ const line = `${new Date().toISOString()} [${process.pid}] ${msg}\n`;
203
+ logStream.write(line);
204
+ };
205
+ log(`daemon starting — home=${home} author=${authorAddress}`);
206
+ // HB#324: do NOT write the PID file yet. A fast-following CLI command
207
+ // that sees the PID file will try to open the IPC socket, which may
208
+ // not yet exist. The startup race has to be closed by writing the PID
209
+ // file AFTER the IPC server is listening. See the end of this function
210
+ // for the actual PID file write.
211
+ // Initialize libp2p once for the whole daemon lifetime. All CLI
212
+ // commands invoked while the daemon is up will (eventually) route
213
+ // through IPC instead of spinning up their own node.
214
+ const node = await (0, brain_1.initBrainNode)();
215
+ log(`libp2p up — peer=${node.libp2p.peerId.toString()}`);
216
+ const stats = {
217
+ startedAt: Date.now(),
218
+ rebroadcastCount: 0,
219
+ rebroadcastsSuppressedBySeen: 0,
220
+ keepaliveCount: 0,
221
+ lastRebroadcastAt: null,
222
+ lastKeepaliveAt: null,
223
+ incomingAnnouncements: 0,
224
+ incomingMerges: 0,
225
+ incomingRejects: 0,
226
+ };
227
+ // T1 (task #429): seen-heads tracking for anti-entropy suppression.
228
+ // When an announcement arrives from a peer, record (docId, cid, receivedAt).
229
+ // The rebroadcast loop checks this map before publishing: if a head was
230
+ // received from any peer less than GRACE_MS ago, suppress the rebroadcast —
231
+ // another agent already did the work, no need to amplify. Keyed by
232
+ // "docId|cid" for O(1) lookup.
233
+ const seenHeads = new Map();
234
+ const seenKey = (docId, cid) => `${docId}|${cid}`;
235
+ // Anti-entropy tuning — read from env, fall back to module defaults.
236
+ // Setting POP_BRAIN_REBROADCAST_INTERVAL_MS=0 disables the loop entirely
237
+ // (useful for unit tests that want deterministic write-path behavior).
238
+ const rebroadcastIntervalMs = (() => {
239
+ const raw = process.env.POP_BRAIN_REBROADCAST_INTERVAL_MS;
240
+ if (raw === undefined)
241
+ return exports.REBROADCAST_INTERVAL_MS;
242
+ const n = parseInt(raw, 10);
243
+ return Number.isFinite(n) && n >= 0 ? n : exports.REBROADCAST_INTERVAL_MS;
244
+ })();
245
+ const rebroadcastJitter = (() => {
246
+ const raw = process.env.POP_BRAIN_REBROADCAST_JITTER;
247
+ if (raw === undefined)
248
+ return exports.REBROADCAST_JITTER;
249
+ const n = parseFloat(raw);
250
+ return Number.isFinite(n) && n >= 0 && n < 1 ? n : exports.REBROADCAST_JITTER;
251
+ })();
252
+ const rebroadcastGraceMs = (() => {
253
+ const raw = process.env.POP_BRAIN_REBROADCAST_GRACE_MS;
254
+ if (raw === undefined)
255
+ return exports.REBROADCAST_GRACE_MS;
256
+ const n = parseInt(raw, 10);
257
+ return Number.isFinite(n) && n >= 0 ? n : exports.REBROADCAST_GRACE_MS;
258
+ })();
259
+ const nextInterval = () => {
260
+ if (rebroadcastJitter <= 0)
261
+ return rebroadcastIntervalMs;
262
+ const delta = rebroadcastIntervalMs * rebroadcastJitter;
263
+ return Math.max(0, Math.round(rebroadcastIntervalMs + (Math.random() * 2 - 1) * delta));
264
+ };
265
+ // --- Subscribe to the keepalive net topic ---
266
+ //
267
+ // The globaldb example publishes "hi!" on a separate netTopic every 20s
268
+ // so that the libp2p ConnManager tags participating peers with a "keep"
269
+ // priority and refuses to evict them. We port that verbatim.
270
+ const pubsub = node.libp2p.services.pubsub;
271
+ pubsub.subscribe(exports.KEEPALIVE_TOPIC);
272
+ log(`subscribed keepalive topic ${exports.KEEPALIVE_TOPIC}`);
273
+ // Tag peers who publish on the keepalive topic (a rough port of
274
+ // h.ConnManager().TagPeer(msg.ReceivedFrom, "keep", 100)). libp2p-js
275
+ // exposes this via node.libp2p.peerStore.merge(peerId, {tags: {...}}).
276
+ const keepaliveListener = async (evt) => {
277
+ const msg = evt.detail;
278
+ if (!msg || msg.topic !== exports.KEEPALIVE_TOPIC)
279
+ return;
280
+ const from = msg.from;
281
+ if (!from)
282
+ return;
283
+ try {
284
+ await node.libp2p.peerStore.merge(from, {
285
+ tags: { 'pop-brain-keep': { value: 50 } },
286
+ });
287
+ }
288
+ catch (err) {
289
+ // Non-fatal; peerStore.merge errors on some libp2p versions if the
290
+ // peer isn't in the store yet. Log and move on.
291
+ log(`keepalive tag err ${from}: ${err.message}`);
292
+ }
293
+ };
294
+ pubsub.addEventListener('message', keepaliveListener);
295
+ // --- Subscribe to every doc topic in the local manifest ---
296
+ //
297
+ // The daemon process keeps these subscriptions alive across CLI
298
+ // invocations. Incoming head-CID announcements fire fetchAndMergeRemoteHead
299
+ // immediately — no more "the CLI exited before the announcement arrived".
300
+ const subscribedDocs = new Set();
301
+ const unsubscribes = [];
302
+ async function subscribeDoc(docId) {
303
+ if (subscribedDocs.has(docId))
304
+ return;
305
+ subscribedDocs.add(docId);
306
+ const unsub = await (0, brain_1.subscribeBrainTopic)(docId, (ann, from) => {
307
+ stats.incomingAnnouncements += 1;
308
+ // T1: record the (docId, cid) we just heard so the rebroadcast loop
309
+ // can skip re-publishing it during the grace window.
310
+ seenHeads.set(seenKey(docId, ann.cid), Date.now());
311
+ log(`recv doc=${docId} cid=${ann.cid} from=${from} author=${ann.author}`);
312
+ // Fire-and-forget the block fetch + merge. Errors are logged.
313
+ (0, brain_1.fetchAndMergeRemoteHead)(ann.docId, ann.cid)
314
+ .then(result => {
315
+ // Task #373: only count actions where content actually landed.
316
+ // 'adopt' = fast-forward or first head, 'merge' = CRDT merge.
317
+ // 'skip' = already at head, 'reject' = auth/fetch/disjoint fail.
318
+ if (result.action === 'adopt' || result.action === 'merge') {
319
+ stats.incomingMerges += 1;
320
+ }
321
+ else if (result.action === 'reject') {
322
+ stats.incomingRejects += 1;
323
+ }
324
+ log(`merge doc=${docId} cid=${ann.cid} action=${result.action} reason=${result.reason}`);
325
+ })
326
+ .catch(err => {
327
+ log(`merge err doc=${docId} cid=${ann.cid}: ${err.message}`);
328
+ });
329
+ });
330
+ unsubscribes.push(unsub);
331
+ log(`subscribed doc ${docId}`);
332
+ }
333
+ // Bootstrap subscription: always subscribe to the canonical well-known
334
+ // doc topics AND every doc currently in the manifest. A fresh brain
335
+ // home will have an empty manifest but still needs to be listening
336
+ // for pop.brain.shared and pop.brain.projects announcements so it
337
+ // can catch up on first contact with a peer.
338
+ const docsToSubscribe = new Set([
339
+ ...exports.CANONICAL_BRAIN_DOCS,
340
+ ...(0, brain_1.listBrainDocs)().map(d => d.docId),
341
+ ]);
342
+ for (const docId of docsToSubscribe) {
343
+ try {
344
+ await subscribeDoc(docId);
345
+ }
346
+ catch (err) {
347
+ log(`subscribe err ${docId}: ${err.message}`);
348
+ }
349
+ }
350
+ // --- Rebroadcast loop (T1, task #429) ---
351
+ //
352
+ // go-ds-crdt default: every 60s ±30% jitter, re-publish current heads so
353
+ // peers that came online after the last write can catch up. Suppresses
354
+ // re-publishing a head we received from a peer within the grace window —
355
+ // avoids amplification when fleet state is already converged.
356
+ //
357
+ // Disabled entirely if POP_BRAIN_REBROADCAST_INTERVAL_MS=0.
358
+ // Implemented as setTimeout-self-rescheduling instead of setInterval so
359
+ // each tick picks a fresh jittered delay.
360
+ let rebroadcastTimer = null;
361
+ async function rebroadcastTick() {
362
+ const docs = (0, brain_1.listBrainDocs)();
363
+ for (const { docId, headCid } of docs) {
364
+ // If the manifest picked up a new doc since startup, make sure we
365
+ // are also subscribed to its topic.
366
+ if (!subscribedDocs.has(docId)) {
367
+ try {
368
+ await subscribeDoc(docId);
369
+ }
370
+ catch { }
371
+ }
372
+ // Suppress rebroadcast of heads seen from peers within the grace
373
+ // window — another agent already published it; we'd just amplify.
374
+ const seenAt = seenHeads.get(seenKey(docId, headCid));
375
+ if (seenAt !== undefined && Date.now() - seenAt < rebroadcastGraceMs) {
376
+ stats.rebroadcastsSuppressedBySeen += 1;
377
+ continue;
378
+ }
379
+ try {
380
+ await (0, brain_1.publishBrainHead)(docId, headCid, authorAddress);
381
+ stats.rebroadcastCount += 1;
382
+ stats.lastRebroadcastAt = Date.now();
383
+ }
384
+ catch (err) {
385
+ log(`rebroadcast err doc=${docId}: ${err.message}`);
386
+ }
387
+ }
388
+ // Prune seenHeads entries older than the grace window — bounded memory.
389
+ const cutoff = Date.now() - rebroadcastGraceMs;
390
+ for (const [key, ts] of seenHeads) {
391
+ if (ts < cutoff)
392
+ seenHeads.delete(key);
393
+ }
394
+ }
395
+ function scheduleRebroadcast() {
396
+ if (rebroadcastIntervalMs === 0)
397
+ return;
398
+ rebroadcastTimer = setTimeout(async () => {
399
+ try {
400
+ await rebroadcastTick();
401
+ }
402
+ catch (err) {
403
+ log(`rebroadcast tick err: ${err.message}`);
404
+ }
405
+ scheduleRebroadcast();
406
+ }, nextInterval());
407
+ }
408
+ scheduleRebroadcast();
409
+ // --- Keepalive loop ---
410
+ //
411
+ // Publish "alive" to the net topic every 20s. This both tags peers and
412
+ // keeps pubsub mesh connections from going idle.
413
+ const keepaliveTimer = setInterval(async () => {
414
+ try {
415
+ await pubsub.publish(exports.KEEPALIVE_TOPIC, new TextEncoder().encode(`alive ${Date.now()}`));
416
+ stats.keepaliveCount += 1;
417
+ stats.lastKeepaliveAt = Date.now();
418
+ }
419
+ catch (err) {
420
+ log(`keepalive err: ${err.message}`);
421
+ }
422
+ }, exports.KEEPALIVE_INTERVAL_MS);
423
+ // --- IPC server ---
424
+ //
425
+ // Unix socket, newline-delimited JSON-RPC. First-ship methods: status.
426
+ // Second-ship will add appendLesson/readDoc/snapshot.
427
+ if ((0, fs_1.existsSync)(sockPath)) {
428
+ try {
429
+ (0, fs_1.unlinkSync)(sockPath);
430
+ }
431
+ catch { }
432
+ }
433
+ const server = net_1.default.createServer(socket => {
434
+ let buf = '';
435
+ socket.on('data', chunk => {
436
+ buf += chunk.toString('utf8');
437
+ let idx;
438
+ while ((idx = buf.indexOf('\n')) >= 0) {
439
+ const line = buf.slice(0, idx);
440
+ buf = buf.slice(idx + 1);
441
+ handleIpcLine(line, socket).catch(err => {
442
+ log(`ipc handler err: ${err.message}`);
443
+ });
444
+ }
445
+ });
446
+ socket.on('error', err => {
447
+ log(`ipc socket err: ${err.message}`);
448
+ });
449
+ });
450
+ async function handleIpcLine(line, socket) {
451
+ if (!line.trim())
452
+ return;
453
+ let req;
454
+ try {
455
+ req = JSON.parse(line);
456
+ }
457
+ catch (err) {
458
+ socket.write(JSON.stringify({ error: `bad json: ${err.message}` }) + '\n');
459
+ return;
460
+ }
461
+ const id = req.id ?? null;
462
+ try {
463
+ const result = await dispatchIpc(req.method, req.params);
464
+ socket.write(JSON.stringify({ id, result }) + '\n');
465
+ }
466
+ catch (err) {
467
+ socket.write(JSON.stringify({ id, error: err.message }) + '\n');
468
+ }
469
+ }
470
+ async function dispatchIpc(method, _params) {
471
+ switch (method) {
472
+ case 'status': {
473
+ const topics = pubsub.getTopics?.() ?? [];
474
+ const peers = node.libp2p.getPeers().map((p) => p.toString());
475
+ const connections = node.libp2p.getConnections().length;
476
+ // Return the full /p2p/<peerId> multiaddrs so operators and test
477
+ // fixtures can dial this daemon from another process when automatic
478
+ // discovery (mDNS, bootstrap) isn't finding it.
479
+ const peerIdStr = node.libp2p.peerId.toString();
480
+ const listenAddrs = [];
481
+ try {
482
+ for (const ma of node.libp2p.getMultiaddrs()) {
483
+ const s = ma.toString();
484
+ listenAddrs.push(s.includes('/p2p/') ? s : `${s}/p2p/${peerIdStr}`);
485
+ }
486
+ }
487
+ catch { }
488
+ return {
489
+ peerId: peerIdStr,
490
+ listenAddrs,
491
+ uptime: Math.floor((Date.now() - stats.startedAt) / 1000),
492
+ connections,
493
+ knownPeerCount: peers.length,
494
+ topics,
495
+ subscribedDocs: Array.from(subscribedDocs),
496
+ rebroadcastCount: stats.rebroadcastCount,
497
+ rebroadcastsSuppressedBySeen: stats.rebroadcastsSuppressedBySeen,
498
+ rebroadcastIntervalMs,
499
+ rebroadcastJitter,
500
+ rebroadcastGraceMs,
501
+ lastRebroadcastAt: stats.lastRebroadcastAt,
502
+ keepaliveCount: stats.keepaliveCount,
503
+ lastKeepaliveAt: stats.lastKeepaliveAt,
504
+ incomingAnnouncements: stats.incomingAnnouncements,
505
+ incomingMerges: stats.incomingMerges,
506
+ incomingRejects: stats.incomingRejects,
507
+ brainHome: home,
508
+ pidPath,
509
+ sockPath,
510
+ logPath,
511
+ };
512
+ }
513
+ case 'dial': {
514
+ // Operator/test escape hatch for bringing peers together when
515
+ // automatic discovery fails. Accepts a `/ip4/.../p2p/<peerId>`
516
+ // multiaddr and asks libp2p to open a connection.
517
+ //
518
+ // Expected params: {multiaddr: string}
519
+ // Returns: {dialed: string, peerId: string}
520
+ const addr = _params?.multiaddr;
521
+ if (!addr || typeof addr !== 'string') {
522
+ throw new Error('dial: multiaddr (string) is required');
523
+ }
524
+ // Lazy import so we don't pull multiaddr into the module namespace
525
+ // unless someone actually uses this path.
526
+ const esmImport = new Function('s', 'return import(s)');
527
+ const { multiaddr: makeMultiaddr } = await esmImport('@multiformats/multiaddr');
528
+ const ma = makeMultiaddr(addr);
529
+ await node.libp2p.dial(ma);
530
+ log(`dial via IPC: ${addr}`);
531
+ return { dialed: addr, peerId: node.libp2p.peerId.toString() };
532
+ }
533
+ case 'ping': {
534
+ return { pong: true, ts: Date.now() };
535
+ }
536
+ case 'applyOp': {
537
+ // HB#324 ship-2: unified write dispatch. The CLI serialized a
538
+ // BrainOp into _params.op; we run it through the same dispatchOp
539
+ // function the CLI would use if no daemon were running. This
540
+ // keeps the local and routed code paths byte-identical — only
541
+ // the transport differs.
542
+ //
543
+ // Lazy import to avoid a module-level cycle: brain-ops imports
544
+ // from brain-daemon (getRunningDaemonPid, sendIpcRequest), and
545
+ // brain-daemon needs to import dispatchOp from brain-ops here.
546
+ // Defer the require until the first applyOp lands.
547
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
548
+ const { dispatchOp } = require('./brain-ops');
549
+ const result = await dispatchOp(_params?.op);
550
+ // If the op touched a doc we weren't subscribed to, add it to
551
+ // our live subscription set so incoming announcements for that
552
+ // doc get handled going forward.
553
+ const docId = _params?.op?.docId;
554
+ if (docId && !subscribedDocs.has(docId)) {
555
+ try {
556
+ await subscribeDoc(docId);
557
+ }
558
+ catch { }
559
+ }
560
+ log(`applyOp doc=${docId} type=${_params?.op?.type} head=${result.headCid} ` +
561
+ `author=${result.envelopeAuthor}`);
562
+ // dispatchOp returns routedViaDaemon=false because it ran in the
563
+ // local (daemon) process. The CLI's routedDispatch will override
564
+ // this flag to true because it was sent via IPC.
565
+ return {
566
+ headCid: result.headCid,
567
+ envelopeAuthor: result.envelopeAuthor,
568
+ };
569
+ }
570
+ default:
571
+ throw new Error(`Unknown IPC method: ${method}`);
572
+ }
573
+ }
574
+ await new Promise((resolve, reject) => {
575
+ server.listen(sockPath, () => resolve());
576
+ server.on('error', reject);
577
+ });
578
+ try {
579
+ (0, fs_1.chmodSync)(sockPath, 0o600);
580
+ }
581
+ catch { }
582
+ log(`IPC listening on ${sockPath}`);
583
+ // HB#324: NOW write the PID file, after the IPC socket is listening.
584
+ // A CLI command that sees the PID file will immediately try to IPC
585
+ // and that connection must succeed on the first try. Writing the PID
586
+ // before the server was listening was a startup race bug in ship-1.
587
+ (0, fs_1.writeFileSync)(pidPath, String(process.pid), { mode: 0o600 });
588
+ log(`PID file written — daemon is now discoverable`);
589
+ // HB#333 (task #349): auto-dial peers listed in POP_BRAIN_PEERS.
590
+ // Format: comma-separated /ip4/.../p2p/<peerId> multiaddrs.
591
+ // Example (3-agent local setup):
592
+ // POP_BRAIN_PEERS=/ip4/127.0.0.1/tcp/50126/p2p/12D3...,/ip4/127.0.0.1/tcp/50134/p2p/12D3...
593
+ //
594
+ // HB#365 (task tbd): periodic redial on disconnect.
595
+ // Before this, POP_BRAIN_PEERS was fire-once at startup — any
596
+ // disconnect (peer reboot, network blip, macOS sleep) left the daemon
597
+ // stuck at connections=0 until manual restart. Now a periodic timer
598
+ // re-evaluates the list every REDIAL_INTERVAL_MS and dials any peer
599
+ // that is not currently in the active connection set. Override the
600
+ // interval via POP_BRAIN_REDIAL_INTERVAL_MS.
601
+ //
602
+ // Semantics:
603
+ // - Unset or empty POP_BRAIN_PEERS → no-op, no timer
604
+ // - Parse each entry, empty segments dropped silently
605
+ // - Initial dial at startup is still best-effort parallel
606
+ // - Every interval tick: for each configured peer, extract the peerId
607
+ // suffix, check libp2p.getPeers() membership. If not connected,
608
+ // dial. If connected, skip (no duplicate dials).
609
+ // - Individual failures logged but never block the timer loop
610
+ // - Timer is cleared in shutdown() alongside rebroadcast/keepalive
611
+ const peersEnv = process.env.POP_BRAIN_PEERS;
612
+ const parsedPeerAddrs = peersEnv && peersEnv.trim() !== ''
613
+ ? peersEnv.split(',').map(s => s.trim()).filter(Boolean)
614
+ : [];
615
+ const esmImportPeers = new Function('s', 'return import(s)');
616
+ let makeMultiaddrLocal = null;
617
+ if (parsedPeerAddrs.length > 0) {
618
+ const mod = await esmImportPeers('@multiformats/multiaddr');
619
+ makeMultiaddrLocal = mod.multiaddr;
620
+ }
621
+ // Extract target peerId from /p2p/<id> suffix so we can test
622
+ // connection membership without re-parsing on every tick.
623
+ function peerIdOfMultiaddr(addr) {
624
+ const m = /\/p2p\/([^/]+)$/.exec(addr);
625
+ return m ? m[1] : null;
626
+ }
627
+ async function dialIfDisconnected(addr, reason) {
628
+ try {
629
+ const targetPeerId = peerIdOfMultiaddr(addr);
630
+ if (targetPeerId) {
631
+ const connected = node.libp2p.getPeers().some((p) => p.toString() === targetPeerId);
632
+ if (connected) {
633
+ // Already connected — no-op. Quiet in redial loop to avoid log spam.
634
+ if (reason === 'startup')
635
+ log(`auto-dial skip (already connected): ${addr}`);
636
+ return;
637
+ }
638
+ }
639
+ const ma = makeMultiaddrLocal(addr);
640
+ await node.libp2p.dial(ma);
641
+ log(`${reason === 'startup' ? 'auto-dial' : 'redial'} success: ${addr}`);
642
+ }
643
+ catch (err) {
644
+ log(`${reason === 'startup' ? 'auto-dial' : 'redial'} failed: ${addr} — ${err?.message ?? err}`);
645
+ }
646
+ }
647
+ if (parsedPeerAddrs.length > 0) {
648
+ log(`auto-dial: POP_BRAIN_PEERS has ${parsedPeerAddrs.length} entry(ies)`);
649
+ // Fire all dials in parallel at startup — independent best-effort.
650
+ await Promise.all(parsedPeerAddrs.map(a => dialIfDisconnected(a, 'startup')));
651
+ }
652
+ // HB#365: periodic redial timer. Handles peer reboots, transient
653
+ // disconnects, and cross-device sessions where the remote side is
654
+ // occasionally offline. Only runs when POP_BRAIN_PEERS is set.
655
+ const redialInterval = (() => {
656
+ const raw = process.env.POP_BRAIN_REDIAL_INTERVAL_MS;
657
+ if (!raw)
658
+ return exports.REDIAL_INTERVAL_MS;
659
+ const n = Number(raw);
660
+ return Number.isFinite(n) && n >= 5_000 ? n : exports.REDIAL_INTERVAL_MS;
661
+ })();
662
+ const redialTimer = parsedPeerAddrs.length > 0
663
+ ? setInterval(async () => {
664
+ for (const addr of parsedPeerAddrs) {
665
+ await dialIfDisconnected(addr, 'redial');
666
+ }
667
+ }, redialInterval)
668
+ : null;
669
+ // --- Graceful shutdown ---
670
+ let shuttingDown = false;
671
+ const shutdown = async (sig) => {
672
+ if (shuttingDown)
673
+ return;
674
+ shuttingDown = true;
675
+ log(`shutdown signal ${sig}`);
676
+ if (rebroadcastTimer !== null)
677
+ clearTimeout(rebroadcastTimer);
678
+ clearInterval(keepaliveTimer);
679
+ if (redialTimer)
680
+ clearInterval(redialTimer);
681
+ try {
682
+ pubsub.removeEventListener('message', keepaliveListener);
683
+ }
684
+ catch { }
685
+ for (const u of unsubscribes) {
686
+ try {
687
+ u();
688
+ }
689
+ catch { }
690
+ }
691
+ await new Promise(res => server.close(() => res()));
692
+ if ((0, fs_1.existsSync)(sockPath)) {
693
+ try {
694
+ (0, fs_1.unlinkSync)(sockPath);
695
+ }
696
+ catch { }
697
+ }
698
+ if ((0, fs_1.existsSync)(pidPath)) {
699
+ try {
700
+ (0, fs_1.unlinkSync)(pidPath);
701
+ }
702
+ catch { }
703
+ }
704
+ try {
705
+ await (0, brain_1.stopBrainNode)();
706
+ }
707
+ catch (err) {
708
+ log(`stop err: ${err.message}`);
709
+ }
710
+ log(`shutdown complete`);
711
+ logStream.end();
712
+ // Give the log stream a tick to flush, then exit.
713
+ setTimeout(() => process.exit(0), 50);
714
+ };
715
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
716
+ process.on('SIGINT', () => shutdown('SIGINT'));
717
+ process.on('SIGHUP', () => shutdown('SIGHUP'));
718
+ process.on('uncaughtException', err => {
719
+ log(`uncaught: ${err.stack ?? err.message}`);
720
+ shutdown('uncaughtException');
721
+ });
722
+ log(`daemon ready — rebroadcast=${rebroadcastIntervalMs === 0 ? 'disabled' : `${rebroadcastIntervalMs}ms±${Math.round(rebroadcastJitter * 100)}% grace=${rebroadcastGraceMs}ms`} ` +
723
+ `keepalive=${exports.KEEPALIVE_INTERVAL_MS}ms ` +
724
+ (redialTimer ? `redial=${redialInterval}ms ` : '') +
725
+ `subscribed=${subscribedDocs.size} docs`);
726
+ // Park forever. The timers and the IPC server keep the event loop alive.
727
+ // Shutdown only happens via signal handler.
728
+ await new Promise(() => { });
729
+ }
730
+ /**
731
+ * Typed IPC error. Attaches a `.code` for the caller to branch on.
732
+ *
733
+ * phase = 'pre-connect' The connection was never established (socket
734
+ * missing, ECONNREFUSED). Safe to fall back to a
735
+ * local execution path — the write did not land
736
+ * in the daemon's process.
737
+ * phase = 'post-connect' The connection was established and the request
738
+ * was sent, but a response did not come back. The
739
+ * write may or may not have landed. NOT safe to
740
+ * fall back — see routedDispatch() in brain-ops.ts.
741
+ */
742
+ class BrainIpcError extends Error {
743
+ code;
744
+ phase;
745
+ constructor(message, code, phase) {
746
+ super(message);
747
+ this.name = 'BrainIpcError';
748
+ this.code = code;
749
+ this.phase = phase;
750
+ }
751
+ }
752
+ exports.BrainIpcError = BrainIpcError;
753
+ /**
754
+ * IPC client helper: send a request to the running daemon.
755
+ *
756
+ * Throws a BrainIpcError whose `.phase` indicates whether the failure is
757
+ * safe to recover from by falling back to a local code path. Pre-connect
758
+ * failures (ECONNREFUSED, ENOENT, daemon not running) are safe. Post-connect
759
+ * failures (timeout, ECONNRESET, EPIPE) leave the write in an unknown state.
760
+ */
761
+ async function sendIpcRequest(method, params = {}, timeoutMs = 5000) {
762
+ const sockPath = getDaemonSockPath();
763
+ if (!(0, fs_1.existsSync)(sockPath)) {
764
+ throw new BrainIpcError(`No brain daemon socket at ${sockPath}. Run "pop brain daemon start".`, 'ENOENT', 'pre-connect');
765
+ }
766
+ return await new Promise((resolve, reject) => {
767
+ const socket = net_1.default.createConnection(sockPath);
768
+ let buf = '';
769
+ let connected = false;
770
+ const timer = setTimeout(() => {
771
+ socket.destroy();
772
+ reject(new BrainIpcError(`IPC timeout after ${timeoutMs}ms`, 'ETIMEDOUT', connected ? 'post-connect' : 'pre-connect'));
773
+ }, timeoutMs);
774
+ socket.on('connect', () => {
775
+ connected = true;
776
+ // Now that we have a live connection, write the request. Doing this
777
+ // in the connect handler (instead of immediately after createConnection)
778
+ // closes a subtle phase-classification race on fast local sockets.
779
+ socket.write(JSON.stringify({ id: Date.now().toString(), method, params }) + '\n');
780
+ });
781
+ socket.on('data', chunk => {
782
+ buf += chunk.toString('utf8');
783
+ const nl = buf.indexOf('\n');
784
+ if (nl >= 0) {
785
+ clearTimeout(timer);
786
+ const line = buf.slice(0, nl);
787
+ try {
788
+ const res = JSON.parse(line);
789
+ socket.end();
790
+ if (res.error) {
791
+ // Response-level error (daemon rejected the request). This is
792
+ // post-connect; the write did not land.
793
+ reject(new BrainIpcError(res.error, 'EHANDLER', 'post-connect'));
794
+ }
795
+ else {
796
+ resolve(res.result);
797
+ }
798
+ }
799
+ catch (err) {
800
+ reject(new BrainIpcError(`bad ipc response: ${err.message}`, 'EPROTO', 'post-connect'));
801
+ }
802
+ }
803
+ });
804
+ socket.on('error', err => {
805
+ clearTimeout(timer);
806
+ const code = err.code ?? 'EIPC';
807
+ const phase = connected ? 'post-connect' : 'pre-connect';
808
+ reject(new BrainIpcError(err.message, code, phase));
809
+ });
810
+ });
811
+ }