@runcore-sh/runcore 0.1.8 → 0.1.9

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 (173) hide show
  1. package/dist/access/manifest.d.ts +59 -0
  2. package/dist/access/manifest.d.ts.map +1 -0
  3. package/dist/access/manifest.js +251 -0
  4. package/dist/access/manifest.js.map +1 -0
  5. package/dist/activity/log.d.ts +1 -1
  6. package/dist/activity/log.d.ts.map +1 -1
  7. package/dist/agents/autonomous.d.ts.map +1 -1
  8. package/dist/agents/autonomous.js +38 -0
  9. package/dist/agents/autonomous.js.map +1 -1
  10. package/dist/agents/governance.d.ts +70 -0
  11. package/dist/agents/governance.d.ts.map +1 -0
  12. package/dist/agents/governance.js +220 -0
  13. package/dist/agents/governance.js.map +1 -0
  14. package/dist/agents/governed-spawn.d.ts +83 -0
  15. package/dist/agents/governed-spawn.d.ts.map +1 -0
  16. package/dist/agents/governed-spawn.js +186 -0
  17. package/dist/agents/governed-spawn.js.map +1 -0
  18. package/dist/agents/heartbeat.d.ts +91 -0
  19. package/dist/agents/heartbeat.d.ts.map +1 -0
  20. package/dist/agents/heartbeat.js +323 -0
  21. package/dist/agents/heartbeat.js.map +1 -0
  22. package/dist/agents/index.d.ts +4 -1
  23. package/dist/agents/index.d.ts.map +1 -1
  24. package/dist/agents/index.js +6 -1
  25. package/dist/agents/index.js.map +1 -1
  26. package/dist/agents/spawn-policy.d.ts +45 -0
  27. package/dist/agents/spawn-policy.d.ts.map +1 -0
  28. package/dist/agents/spawn-policy.js +202 -0
  29. package/dist/agents/spawn-policy.js.map +1 -0
  30. package/dist/alert.d.ts +16 -0
  31. package/dist/alert.d.ts.map +1 -0
  32. package/dist/alert.js +70 -0
  33. package/dist/alert.js.map +1 -0
  34. package/dist/cli.js +35 -27
  35. package/dist/cli.js.map +1 -1
  36. package/dist/credentials/store.d.ts +1 -1
  37. package/dist/credentials/store.d.ts.map +1 -1
  38. package/dist/credentials/store.js +14 -3
  39. package/dist/credentials/store.js.map +1 -1
  40. package/dist/crystallizer.d.ts +56 -0
  41. package/dist/crystallizer.d.ts.map +1 -0
  42. package/dist/crystallizer.js +159 -0
  43. package/dist/crystallizer.js.map +1 -0
  44. package/dist/distiller.d.ts +48 -0
  45. package/dist/distiller.d.ts.map +1 -0
  46. package/dist/distiller.js +140 -0
  47. package/dist/distiller.js.map +1 -0
  48. package/dist/google/auth.d.ts +2 -0
  49. package/dist/google/auth.d.ts.map +1 -1
  50. package/dist/google/auth.js +2 -0
  51. package/dist/google/auth.js.map +1 -1
  52. package/dist/integrations/gate.d.ts +40 -0
  53. package/dist/integrations/gate.d.ts.map +1 -0
  54. package/dist/integrations/gate.js +100 -0
  55. package/dist/integrations/gate.js.map +1 -0
  56. package/dist/lib/audit.d.ts +43 -0
  57. package/dist/lib/audit.d.ts.map +1 -0
  58. package/dist/lib/audit.js +120 -0
  59. package/dist/lib/audit.js.map +1 -0
  60. package/dist/lib/brain-io.d.ts.map +1 -1
  61. package/dist/lib/brain-io.js +52 -0
  62. package/dist/lib/brain-io.js.map +1 -1
  63. package/dist/lib/dpapi.d.ts +14 -0
  64. package/dist/lib/dpapi.d.ts.map +1 -0
  65. package/dist/lib/dpapi.js +104 -0
  66. package/dist/lib/dpapi.js.map +1 -0
  67. package/dist/lib/glob-match.d.ts +22 -0
  68. package/dist/lib/glob-match.d.ts.map +1 -0
  69. package/dist/lib/glob-match.js +64 -0
  70. package/dist/lib/glob-match.js.map +1 -0
  71. package/dist/lib/locked.d.ts +40 -0
  72. package/dist/lib/locked.d.ts.map +1 -0
  73. package/dist/lib/locked.js +130 -0
  74. package/dist/lib/locked.js.map +1 -0
  75. package/dist/llm/complete.d.ts.map +1 -1
  76. package/dist/llm/complete.js +5 -2
  77. package/dist/llm/complete.js.map +1 -1
  78. package/dist/llm/fetch-guard.d.ts +16 -0
  79. package/dist/llm/fetch-guard.d.ts.map +1 -0
  80. package/dist/llm/fetch-guard.js +61 -0
  81. package/dist/llm/fetch-guard.js.map +1 -0
  82. package/dist/llm/guard.d.ts +40 -0
  83. package/dist/llm/guard.d.ts.map +1 -0
  84. package/dist/llm/guard.js +88 -0
  85. package/dist/llm/guard.js.map +1 -0
  86. package/dist/llm/membrane.d.ts +46 -0
  87. package/dist/llm/membrane.d.ts.map +1 -0
  88. package/dist/llm/membrane.js +123 -0
  89. package/dist/llm/membrane.js.map +1 -0
  90. package/dist/llm/providers/index.d.ts +5 -1
  91. package/dist/llm/providers/index.d.ts.map +1 -1
  92. package/dist/llm/providers/index.js +8 -1
  93. package/dist/llm/providers/index.js.map +1 -1
  94. package/dist/llm/redact.d.ts +39 -0
  95. package/dist/llm/redact.d.ts.map +1 -0
  96. package/dist/llm/redact.js +155 -0
  97. package/dist/llm/redact.js.map +1 -0
  98. package/dist/llm/sensitive-registry.d.ts +33 -0
  99. package/dist/llm/sensitive-registry.d.ts.map +1 -0
  100. package/dist/llm/sensitive-registry.js +106 -0
  101. package/dist/llm/sensitive-registry.js.map +1 -0
  102. package/dist/mcp-server.d.ts +11 -0
  103. package/dist/mcp-server.d.ts.map +1 -0
  104. package/dist/mcp-server.js +520 -0
  105. package/dist/mcp-server.js.map +1 -0
  106. package/dist/mdns.d.ts +17 -0
  107. package/dist/mdns.d.ts.map +1 -0
  108. package/dist/mdns.js +110 -0
  109. package/dist/mdns.js.map +1 -0
  110. package/dist/nerve/push.d.ts +26 -0
  111. package/dist/nerve/push.d.ts.map +1 -0
  112. package/dist/nerve/push.js +170 -0
  113. package/dist/nerve/push.js.map +1 -0
  114. package/dist/nerve/state.d.ts +35 -0
  115. package/dist/nerve/state.d.ts.map +1 -0
  116. package/dist/nerve/state.js +257 -0
  117. package/dist/nerve/state.js.map +1 -0
  118. package/dist/resend/inbox.d.ts +23 -0
  119. package/dist/resend/inbox.d.ts.map +1 -0
  120. package/dist/resend/inbox.js +198 -0
  121. package/dist/resend/inbox.js.map +1 -0
  122. package/dist/resend/webhooks.d.ts +30 -0
  123. package/dist/resend/webhooks.d.ts.map +1 -0
  124. package/dist/resend/webhooks.js +244 -0
  125. package/dist/resend/webhooks.js.map +1 -0
  126. package/dist/server.d.ts +2 -0
  127. package/dist/server.d.ts.map +1 -1
  128. package/dist/server.js +585 -16
  129. package/dist/server.js.map +1 -1
  130. package/dist/settings.d.ts +14 -1
  131. package/dist/settings.d.ts.map +1 -1
  132. package/dist/settings.js +32 -1
  133. package/dist/settings.js.map +1 -1
  134. package/dist/updater.d.ts +32 -0
  135. package/dist/updater.d.ts.map +1 -0
  136. package/dist/updater.js +145 -0
  137. package/dist/updater.js.map +1 -0
  138. package/dist/vault/policy.d.ts +42 -0
  139. package/dist/vault/policy.d.ts.map +1 -0
  140. package/dist/vault/policy.js +159 -0
  141. package/dist/vault/policy.js.map +1 -0
  142. package/dist/vault/store.d.ts +6 -0
  143. package/dist/vault/store.d.ts.map +1 -1
  144. package/dist/vault/store.js +15 -5
  145. package/dist/vault/store.js.map +1 -1
  146. package/dist/vault/transfer.d.ts +33 -0
  147. package/dist/vault/transfer.d.ts.map +1 -0
  148. package/dist/vault/transfer.js +187 -0
  149. package/dist/vault/transfer.js.map +1 -0
  150. package/dist/voucher.d.ts +39 -0
  151. package/dist/voucher.d.ts.map +1 -0
  152. package/dist/voucher.js +105 -0
  153. package/dist/voucher.js.map +1 -0
  154. package/dist/webhooks/handlers.d.ts +10 -0
  155. package/dist/webhooks/handlers.d.ts.map +1 -1
  156. package/dist/webhooks/handlers.js +53 -0
  157. package/dist/webhooks/handlers.js.map +1 -1
  158. package/dist/webhooks/index.d.ts +2 -2
  159. package/dist/webhooks/index.d.ts.map +1 -1
  160. package/dist/webhooks/index.js +2 -2
  161. package/dist/webhooks/index.js.map +1 -1
  162. package/dist/webhooks/verify.d.ts +8 -0
  163. package/dist/webhooks/verify.d.ts.map +1 -1
  164. package/dist/webhooks/verify.js +56 -0
  165. package/dist/webhooks/verify.js.map +1 -1
  166. package/package.json +8 -2
  167. package/public/board.html +8 -3
  168. package/public/browser.html +8 -3
  169. package/public/library.html +8 -3
  170. package/public/observatory.html +8 -3
  171. package/public/ops.html +8 -3
  172. package/public/registry.html +627 -0
  173. package/public/roadmap.html +975 -0
package/dist/server.js CHANGED
@@ -16,6 +16,7 @@ const PKG_ROOT = join(__dirname, "..");
16
16
  import { writeFileSync } from "node:fs";
17
17
  import { initInstanceName, getInstanceName, getInstanceNameLower, resolveEnv, getAlertEmailFrom } from "./instance.js";
18
18
  import { readBrainFile, writeBrainFile, appendBrainLine } from "./lib/brain-io.js";
19
+ import { runWithAuditContext } from "./lib/audit.js";
19
20
  import { Brain } from "./brain.js";
20
21
  import { FileSystemLongTermMemory } from "./memory/file-backed.js";
21
22
  import { createLogger } from "./utils/logger.js";
@@ -25,7 +26,13 @@ import { ensurePairingCode, getStatus, pair, authenticate, getRecoveryQuestion,
25
26
  import { getProvider } from "./llm/providers/index.js";
26
27
  import { withStreamRetry } from "./llm/retry.js";
27
28
  import { LLMError } from "./llm/errors.js";
28
- import { loadSettings, getSettings, updateSettings, resolveProvider, resolveChatModel, resolveUtilityModel, getPulseSettings, } from "./settings.js";
29
+ import { PrivateModeError, isPrivateMode, checkOllamaHealth } from "./llm/guard.js";
30
+ import { installFetchGuard } from "./llm/fetch-guard.js";
31
+ import { SensitiveRegistry } from "./llm/sensitive-registry.js";
32
+ import { PrivacyMembrane } from "./llm/membrane.js";
33
+ import { setActiveMembrane, rehydrateResponse } from "./llm/redact.js";
34
+ import { loadSettings, getSettings, updateSettings, resolveProvider, resolveChatModel, resolveUtilityModel, getPulseSettings, getMeshConfig, } from "./settings.js";
35
+ import { startMdns, stopMdns } from "./mdns.js";
29
36
  import { ingestDirectory } from "./files/ingest.js";
30
37
  import { processIngestFolder } from "./files/ingest-folder.js";
31
38
  import { saveSession, loadSession } from "./sessions/store.js";
@@ -42,7 +49,9 @@ import { isSttAvailable, transcribe } from "./stt/client.js";
42
49
  import { startAvatarSidecar, stopAvatarSidecar, isAvatarAvailable } from "./avatar/sidecar.js";
43
50
  import { preparePhoto, generateVideo, getCachedVideo, cacheVideo, clearVideoCache } from "./avatar/client.js";
44
51
  import { getTtsConfig, getSttConfig, getAvatarConfig } from "./settings.js";
45
- import { loadVault, listVaultKeys, setVaultKey, deleteVaultKey, getDashReadableVault, getVaultEntries } from "./vault/store.js";
52
+ import { loadVault, listVaultKeys, setVaultKey, deleteVaultKey, getDashReadableVault, getVaultEntries, hydrateEnv as rehydrateVaultEnv } from "./vault/store.js";
53
+ import { exportVault, importVault, verifyExport } from "./vault/transfer.js";
54
+ import { getIntegrationStatus, isIntegrationEnabled } from "./integrations/gate.js";
46
55
  import { makeCall } from "./twilio/call.js";
47
56
  import { isGoogleConfigured, isGoogleAuthenticated, getAuthUrl, exchangeCode, } from "./google/auth.js";
48
57
  import { isCalendarAvailable, getTodaySchedule, getUpcomingEvents, listEvents, searchEvents, getFreeBusy, createEvent, updateEvent, deleteEvent } from "./google/calendar.js";
@@ -100,6 +109,7 @@ import { listChannels, getChannelInfo, joinChannel, getChannelHistory } from "./
100
109
  // Slack types no longer needed — providers handle their own type mapping
101
110
  import { getClient as getWhatsAppClient, isWhatsAppConfigured } from "./channels/whatsapp.js";
102
111
  import { parseFormBody, processIncomingMessage, emptyTwimlResponse, replyTwiml, twilioProvider } from "./webhooks/twilio.js";
112
+ import { resendProvider } from "./resend/webhooks.js";
103
113
  import { handleWhatsAppMessage } from "./services/whatsapp.js";
104
114
  import { initLLMCache, shutdownLLMCache, getCacheDiagnostics } from "./cache/llm-cache.js";
105
115
  import { mountWebhookAdmin, createWebhookRoute, verifyWebhookRequest } from "./webhooks/mount.js";
@@ -122,7 +132,8 @@ function pickStreamFn() {
122
132
  return withStreamRetry((options) => provider.streamChat(options), { maxRetries: 3, baseDelayMs: 1_000, maxDelayMs: 30_000 });
123
133
  }
124
134
  // --- Config ---
125
- const PORT = parseInt(process.env.CORE_PORT ?? resolveEnv("PORT") ?? "3577", 10);
135
+ const PORT = parseInt(process.env.CORE_PORT ?? resolveEnv("PORT") ?? "0", 10);
136
+ let actualPort = PORT;
126
137
  const SIDECAR_PORT = resolveEnv("SEARCH_PORT") ?? "3578";
127
138
  const BRAIN_DIR = join(process.cwd(), "brain");
128
139
  const SKILLS_DIR = join(process.cwd(), "skills");
@@ -177,7 +188,7 @@ async function getOrCreateChatSession(sessionId, name) {
177
188
  // Read custom personality instructions (empty string if file doesn't exist)
178
189
  let personality = "";
179
190
  try {
180
- personality = (await readBrainFile(PERSONALITY_PATH)).trim();
191
+ personality = (await runWithAuditContext({ caller: "http:init:personality", channel: "http" }, () => readBrainFile(PERSONALITY_PATH))).trim();
181
192
  }
182
193
  catch { }
183
194
  // Fetch current projects for system prompt injection
@@ -362,6 +373,17 @@ app.get("/", async (c) => {
362
373
  const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "index.html"));
363
374
  return c.html(html);
364
375
  });
376
+ // --- Nerve endpoint (PWA) ---
377
+ app.get("/nerve", async (c) => {
378
+ const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "nerve", "index.html"));
379
+ return c.html(html);
380
+ });
381
+ // --- Audit context middleware ---
382
+ // Tags all brain file reads within HTTP handlers with the route info.
383
+ app.use("/api/*", async (c, next) => {
384
+ const caller = `http:${c.req.method} ${c.req.path}`;
385
+ return runWithAuditContext({ caller, channel: "http" }, () => next());
386
+ });
365
387
  // --- Tracing middleware ---
366
388
  app.use("/api/*", tracingMiddleware());
367
389
  // --- Metrics middleware ---
@@ -389,7 +411,7 @@ const webhookInitStart = performance.now();
389
411
  // Phase 1: Batch-register all webhook providers (deferred from module imports to avoid
390
412
  // 5 individual logActivity calls during startup — now a single batch call).
391
413
  const registerStart = performance.now();
392
- registerProviders([githubProvider, slackEventsProvider, slackCommandsProvider, slackInteractionsProvider, twilioProvider]);
414
+ registerProviders([githubProvider, slackEventsProvider, slackCommandsProvider, slackInteractionsProvider, twilioProvider, resendProvider]);
393
415
  const registerMs = performance.now() - registerStart;
394
416
  // Phase 2: Configure webhook providers (secrets resolved from env vars)
395
417
  const configStart = performance.now();
@@ -399,6 +421,7 @@ setProviderConfigs([
399
421
  { name: "slack-interactions", secret: "SLACK_SIGNING_SECRET", signatureHeader: "x-slack-signature", algorithm: "slack-v0", path: "/api/slack/interactions" },
400
422
  { name: "twilio", secret: "TWILIO_AUTH_TOKEN", signatureHeader: "x-twilio-signature", algorithm: "twilio", path: "/api/twilio/whatsapp" },
401
423
  { name: "github", secret: "GITHUB_WEBHOOK_SECRET", signatureHeader: "x-hub-signature-256", algorithm: "hmac-sha256-hex", path: "/api/github/webhooks" },
424
+ { name: "resend", secret: "RESEND_WEBHOOK_SECRET", signatureHeader: "svix-signature", algorithm: "custom", path: "/api/resend/webhooks" },
402
425
  ]);
403
426
  const configMs = performance.now() - configStart;
404
427
  // Phase 3: Mount admin routes
@@ -422,6 +445,7 @@ app.get("/api/status", async (c) => {
422
445
  ...status,
423
446
  provider: resolveProvider(),
424
447
  airplaneMode: settings.airplaneMode,
448
+ privateMode: settings.privateMode,
425
449
  safeWordMode: settings.safeWordMode,
426
450
  search: isSearchAvailable(),
427
451
  tts: isTtsAvailable(),
@@ -579,6 +603,70 @@ app.delete("/api/vault/:name", async (c) => {
579
603
  await deleteVaultKey(name, key);
580
604
  return c.json({ ok: true });
581
605
  });
606
+ // Export vault to portable passphrase-encrypted file
607
+ app.post("/api/vault/export", async (c) => {
608
+ const sessionId = c.req.query("sessionId");
609
+ if (!sessionId)
610
+ return c.json({ error: "sessionId required" }, 400);
611
+ const session = validateSession(sessionId);
612
+ if (!session)
613
+ return c.json({ error: "Invalid or expired session" }, 401);
614
+ const body = await c.req.json();
615
+ if (!body.passphrase || body.passphrase.length < 8) {
616
+ return c.json({ error: "Passphrase required (min 8 characters)" }, 400);
617
+ }
618
+ try {
619
+ const result = await exportVault(body.passphrase);
620
+ return c.json({ ok: true, filePath: result.filePath, stats: result.stats });
621
+ }
622
+ catch (e) {
623
+ return c.json({ error: e.message }, 500);
624
+ }
625
+ });
626
+ // Import vault from portable passphrase-encrypted file
627
+ app.post("/api/vault/import", async (c) => {
628
+ const sessionId = c.req.query("sessionId");
629
+ if (!sessionId)
630
+ return c.json({ error: "sessionId required" }, 400);
631
+ const session = validateSession(sessionId);
632
+ if (!session)
633
+ return c.json({ error: "Invalid or expired session" }, 401);
634
+ const key = sessionKeys.get(sessionId);
635
+ if (!key)
636
+ return c.json({ error: "Session key not found" }, 401);
637
+ const body = await c.req.json();
638
+ if (!body.filePath || !body.passphrase) {
639
+ return c.json({ error: "filePath and passphrase required" }, 400);
640
+ }
641
+ const strategy = body.strategy ?? "skip";
642
+ try {
643
+ const result = await importVault(body.filePath, body.passphrase, strategy, key);
644
+ return c.json({ ok: true, stats: result.stats });
645
+ }
646
+ catch (e) {
647
+ return c.json({ error: e.message }, 400);
648
+ }
649
+ });
650
+ // Verify a vault export file without importing
651
+ app.post("/api/vault/verify-export", async (c) => {
652
+ const sessionId = c.req.query("sessionId");
653
+ if (!sessionId)
654
+ return c.json({ error: "sessionId required" }, 400);
655
+ const session = validateSession(sessionId);
656
+ if (!session)
657
+ return c.json({ error: "Invalid or expired session" }, 401);
658
+ const body = await c.req.json();
659
+ if (!body.filePath || !body.passphrase) {
660
+ return c.json({ error: "filePath and passphrase required" }, 400);
661
+ }
662
+ try {
663
+ const result = await verifyExport(body.filePath, body.passphrase);
664
+ return c.json({ ok: true, message: result.message, stats: result.stats });
665
+ }
666
+ catch (e) {
667
+ return c.json({ error: e.message }, 400);
668
+ }
669
+ });
582
670
  // --- Google OAuth2 routes ---
583
671
  // Initiate Google OAuth flow — redirects to Google consent screen
584
672
  // No session required: this just redirects to Google, no sensitive data returned
@@ -1178,6 +1266,43 @@ app.put("/api/settings", async (c) => {
1178
1266
  },
1179
1267
  });
1180
1268
  });
1269
+ // --- Integration admin routes ---
1270
+ app.get("/api/admin/integrations", async (c) => {
1271
+ const settings = getSettings();
1272
+ const integrations = settings.integrations ?? { enabled: true };
1273
+ const status = getIntegrationStatus();
1274
+ return c.json({
1275
+ enabled: integrations.enabled ?? true,
1276
+ services: status,
1277
+ });
1278
+ });
1279
+ app.post("/api/admin/integrations", async (c) => {
1280
+ const body = await c.req.json();
1281
+ const patch = {
1282
+ integrations: {},
1283
+ };
1284
+ if (typeof body.enabled === "boolean") {
1285
+ patch.integrations.enabled = body.enabled;
1286
+ }
1287
+ if (body.services && typeof body.services === "object") {
1288
+ patch.integrations.services = body.services;
1289
+ }
1290
+ const updated = await updateSettings(patch);
1291
+ // Re-hydrate env with new gates — clear integration vars first, then re-hydrate
1292
+ const status = getIntegrationStatus();
1293
+ rehydrateVaultEnv();
1294
+ const credStore = getCredentialStore();
1295
+ if (credStore)
1296
+ await credStore.hydrate();
1297
+ return c.json({
1298
+ enabled: updated.integrations?.enabled ?? true,
1299
+ services: status.map((s) => ({
1300
+ ...s,
1301
+ // Re-check after settings update
1302
+ enabled: isIntegrationEnabled(s.service),
1303
+ })),
1304
+ });
1305
+ });
1181
1306
  // --- Voice routes ---
1182
1307
  // Voice status: which voice features are available?
1183
1308
  app.get("/api/voice-status", async (c) => {
@@ -1386,7 +1511,16 @@ app.post("/api/branch", async (c) => {
1386
1511
  ];
1387
1512
  const activeProvider = resolveProvider();
1388
1513
  const activeChatModel = resolveChatModel();
1389
- const stream_fn = pickStreamFn();
1514
+ let stream_fn;
1515
+ try {
1516
+ stream_fn = pickStreamFn();
1517
+ }
1518
+ catch (err) {
1519
+ if (err instanceof PrivateModeError) {
1520
+ return c.json({ error: err.message }, 503);
1521
+ }
1522
+ throw err;
1523
+ }
1390
1524
  const reqSignal = c.req.raw.signal;
1391
1525
  return streamSSE(c, async (stream) => {
1392
1526
  // Send branch trace metadata so the UI can track lineage
@@ -1407,21 +1541,42 @@ app.post("/api/branch", async (c) => {
1407
1541
  }
1408
1542
  const onAbort = () => resolve();
1409
1543
  reqSignal?.addEventListener("abort", onAbort, { once: true });
1544
+ // Token buffer for split-placeholder rehydration
1545
+ let tokenBuf = "";
1546
+ const flushBuf = () => {
1547
+ if (!tokenBuf)
1548
+ return;
1549
+ const rehydrated = rehydrateResponse(tokenBuf);
1550
+ tokenBuf = "";
1551
+ stream.writeSSE({ data: JSON.stringify({ token: rehydrated }) }).catch(() => { });
1552
+ };
1410
1553
  stream_fn({
1411
1554
  messages,
1412
1555
  model: activeChatModel,
1413
1556
  signal: reqSignal,
1414
1557
  onToken: (token) => {
1415
- stream.writeSSE({ data: JSON.stringify({ token }) }).catch(() => { });
1558
+ tokenBuf += token;
1559
+ // Hold if buffer ends with partial placeholder: << ... (no closing >>)
1560
+ const lastOpen = tokenBuf.lastIndexOf("<<");
1561
+ if (lastOpen !== -1 && tokenBuf.indexOf(">>", lastOpen) === -1)
1562
+ return;
1563
+ flushBuf();
1416
1564
  },
1417
1565
  onDone: () => {
1566
+ flushBuf(); // flush remainder
1418
1567
  reqSignal?.removeEventListener("abort", onAbort);
1419
1568
  stream.writeSSE({ data: JSON.stringify({ done: true }) }).catch(() => { });
1420
1569
  resolve();
1421
1570
  },
1422
- onError: (err) => {
1571
+ onError: async (err) => {
1572
+ flushBuf();
1423
1573
  reqSignal?.removeEventListener("abort", onAbort);
1424
- const errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
1574
+ let errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
1575
+ if (isPrivateMode() && /ECONNREFUSED|fetch failed|network|socket/i.test(errorMsg)) {
1576
+ const health = await checkOllamaHealth();
1577
+ if (!health.ok)
1578
+ errorMsg += " — Check that Ollama is running. " + health.message;
1579
+ }
1425
1580
  stream.writeSSE({ data: JSON.stringify({ error: errorMsg }) }).catch(() => { });
1426
1581
  resolve();
1427
1582
  },
@@ -2076,6 +2231,22 @@ app.post("/api/slack/commands", createWebhookRoute({ provider: "slack-commands"
2076
2231
  // Slack interactions: routed through the generic webhook system.
2077
2232
  // The slack-interactions provider handles extracting JSON from the form "payload" field.
2078
2233
  app.post("/api/slack/interactions", createWebhookRoute({ provider: "slack-interactions" }));
2234
+ // --- Resend inbound email ---
2235
+ // Resend webhook: receive inbound emails via Svix-signed webhooks (direct path).
2236
+ // Uses generic webhook route with Svix signature verification.
2237
+ app.post("/api/resend/webhooks", createWebhookRoute({ provider: "resend" }));
2238
+ // Resend inbox: manually trigger inbox check (pulls from Worker KV).
2239
+ app.post("/api/resend/check-inbox", async (c) => {
2240
+ const sessionId = c.req.query("sessionId");
2241
+ if (!sessionId)
2242
+ return c.json({ error: "sessionId required" }, 400);
2243
+ const session = validateSession(sessionId);
2244
+ if (!session)
2245
+ return c.json({ error: "Invalid or expired session" }, 401);
2246
+ const { forceCheckResendInbox } = await import("./resend/inbox.js");
2247
+ const count = await forceCheckResendInbox();
2248
+ return c.json({ ok: true, processed: count });
2249
+ });
2079
2250
  // --- WhatsApp routes (Twilio-backed) ---
2080
2251
  // WhatsApp status: check if configured
2081
2252
  app.get("/api/whatsapp/status", (c) => {
@@ -2147,6 +2318,59 @@ app.post("/api/relay/whatsapp", async (c) => {
2147
2318
  const result = await handleWhatsAppMessage(from, body, payload.ProfileName);
2148
2319
  return c.json({ ok: result.ok, reply: result.reply, error: result.error });
2149
2320
  });
2321
+ // Resend relay: receive pre-verified inbound emails from the Cloudflare Worker.
2322
+ // The Worker has already verified Resend's Svix signature and fetched the full
2323
+ // email body — this endpoint verifies the relay's own HMAC-SHA256 signature.
2324
+ app.post("/api/relay/resend", async (c) => {
2325
+ const rawBody = await c.req.text();
2326
+ const headers = {};
2327
+ c.req.raw.headers.forEach((value, key) => {
2328
+ headers[key.toLowerCase()] = value;
2329
+ });
2330
+ const relaySecret = process.env.RELAY_SECRET ?? "";
2331
+ const verification = verifyRelaySignature(rawBody, headers, relaySecret);
2332
+ if (!verification.valid) {
2333
+ return c.json({ error: verification.error ?? "Invalid relay signature" }, 401);
2334
+ }
2335
+ let payload;
2336
+ try {
2337
+ payload = JSON.parse(rawBody);
2338
+ }
2339
+ catch {
2340
+ return c.json({ error: "Invalid JSON" }, 400);
2341
+ }
2342
+ if (payload.type !== "email.received" || !payload.body?.trim()) {
2343
+ return c.json({ ok: true, message: "No actionable content" });
2344
+ }
2345
+ logActivity({
2346
+ source: "resend",
2347
+ summary: `Inbound email relayed from ${payload.from}: "${payload.subject}"`,
2348
+ });
2349
+ // Import processInboundEmail dynamically to avoid circular deps at module level
2350
+ const { processInboundEmail, sendResendReply } = await import("./resend/webhooks.js");
2351
+ const reply = await processInboundEmail({
2352
+ from: payload.from,
2353
+ subject: payload.subject,
2354
+ body: payload.body,
2355
+ date: payload.created_at || new Date().toISOString(),
2356
+ });
2357
+ if (!reply) {
2358
+ return c.json({ ok: true, message: "No reply generated" });
2359
+ }
2360
+ const sent = await sendResendReply({
2361
+ to: payload.from,
2362
+ subject: payload.subject,
2363
+ body: reply,
2364
+ inReplyTo: payload.message_id,
2365
+ });
2366
+ logActivity({
2367
+ source: "resend",
2368
+ summary: sent
2369
+ ? `Replied to ${payload.from}: "${payload.subject}" (${reply.length} chars)`
2370
+ : `Failed to reply to ${payload.from}: "${payload.subject}"`,
2371
+ });
2372
+ return c.json({ ok: true, sent, replyLength: reply.length });
2373
+ });
2150
2374
  // WhatsApp send: send a message (requires sessionId)
2151
2375
  app.post("/api/whatsapp/send", async (c) => {
2152
2376
  const sessionId = c.req.query("sessionId");
@@ -3046,6 +3270,92 @@ app.get("/browser", async (c) => {
3046
3270
  const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "browser.html"));
3047
3271
  return c.html(html);
3048
3272
  });
3273
+ // Serve registry.html (service & capability dashboard)
3274
+ app.get("/registry", async (c) => {
3275
+ const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "registry.html"));
3276
+ return c.html(html);
3277
+ });
3278
+ // Serve roadmap.html (strategic roadmap & rearview)
3279
+ app.get("/roadmap", async (c) => {
3280
+ const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "roadmap.html"));
3281
+ return c.html(html);
3282
+ });
3283
+ // Roadmap API — parse brain/operations/roadmap.yaml and return as JSON
3284
+ app.get("/api/roadmap", async (c) => {
3285
+ try {
3286
+ const raw = await readBrainFile(join(process.cwd(), "brain", "operations", "roadmap.yaml"));
3287
+ const parsed = parseRoadmapYaml(raw);
3288
+ return c.json(parsed);
3289
+ }
3290
+ catch (err) {
3291
+ return c.json({ error: "Failed to load roadmap.yaml" }, 500);
3292
+ }
3293
+ });
3294
+ // Roadmap rearview API — recent git commits grouped by hour
3295
+ app.get("/api/roadmap/recent", async (c) => {
3296
+ const hours = parseInt(c.req.query("hours") || "24", 10);
3297
+ if (isNaN(hours) || hours < 1 || hours > 168) {
3298
+ return c.json({ error: "hours must be between 1 and 168" }, 400);
3299
+ }
3300
+ try {
3301
+ const { execSync } = await import("child_process");
3302
+ const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
3303
+ const raw = execSync(`git log --after="${since}" --format="%H||%an||%ai||%s" --no-merges`, { cwd: process.cwd(), encoding: "utf-8", timeout: 10000 }).trim();
3304
+ if (!raw)
3305
+ return c.json({ commits: [], groups: [], hours, total: 0 });
3306
+ const commits = raw.split("\n").map((line) => {
3307
+ const [hash, author, date, ...msgParts] = line.split("||");
3308
+ const message = msgParts.join("||");
3309
+ const isAuto = /^\[(?:dash|agent)\]/i.test(message);
3310
+ return {
3311
+ hash: hash.slice(0, 8),
3312
+ fullHash: hash,
3313
+ author,
3314
+ date,
3315
+ message,
3316
+ autonomous: isAuto,
3317
+ tag: isAuto ? "dash" : "human",
3318
+ };
3319
+ });
3320
+ // Group by hour bucket
3321
+ const groups = {};
3322
+ for (const commit of commits) {
3323
+ const d = new Date(commit.date);
3324
+ const hourKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:00`;
3325
+ if (!groups[hourKey])
3326
+ groups[hourKey] = [];
3327
+ groups[hourKey].push(commit);
3328
+ }
3329
+ const grouped = Object.entries(groups)
3330
+ .map(([hour, items]) => ({ hour, commits: items, count: items.length }))
3331
+ .sort((a, b) => b.hour.localeCompare(a.hour));
3332
+ return c.json({ commits, groups: grouped, hours, total: commits.length });
3333
+ }
3334
+ catch (err) {
3335
+ const msg = err instanceof Error ? err.message : String(err);
3336
+ return c.json({ error: "Failed to read git log: " + msg }, 500);
3337
+ }
3338
+ });
3339
+ // Serve personal.html (placeholder — instances populate this)
3340
+ app.get("/personal", async (c) => {
3341
+ try {
3342
+ const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "personal.html"));
3343
+ return c.html(html);
3344
+ }
3345
+ catch {
3346
+ return c.text("Personal page not configured for this instance.", 404);
3347
+ }
3348
+ });
3349
+ // Serve life.html (placeholder — instances populate this)
3350
+ app.get("/life", async (c) => {
3351
+ try {
3352
+ const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "life.html"));
3353
+ return c.html(html);
3354
+ }
3355
+ catch {
3356
+ return c.text("Life page not configured for this instance.", 404);
3357
+ }
3358
+ });
3049
3359
  // Browse API — fetch a URL and return what the agent sees (stripped text)
3050
3360
  app.get("/api/browse", async (c) => {
3051
3361
  const url = c.req.query("url");
@@ -3159,6 +3469,7 @@ app.get("/api/ops/health", async (c) => {
3159
3469
  },
3160
3470
  provider: resolveProvider(),
3161
3471
  airplaneMode: settings.airplaneMode,
3472
+ privateMode: settings.privateMode,
3162
3473
  models: settings.models,
3163
3474
  sidecars: {
3164
3475
  search: isSidecarAvailable(),
@@ -3393,6 +3704,83 @@ app.get("/api/pulse/history", (c) => {
3393
3704
  }
3394
3705
  return c.json(integrator.getVoltageHistory());
3395
3706
  });
3707
+ // --- Nerve API (three-dot goo) ---
3708
+ import { getNerveState } from "./nerve/state.js";
3709
+ import { initPush, getVapidPublicKey, addSubscription, checkAndNotify, startPushMonitor, stopPushMonitor } from "./nerve/push.js";
3710
+ // State endpoint — three dots
3711
+ app.get("/api/nerve/state", async (c) => {
3712
+ const state = await getNerveState();
3713
+ return c.json(state);
3714
+ });
3715
+ // VAPID public key for push subscription
3716
+ app.get("/api/nerve/vapid-key", (c) => {
3717
+ try {
3718
+ return c.json({ key: getVapidPublicKey() });
3719
+ }
3720
+ catch {
3721
+ return c.json({ error: "Push not initialized" }, 503);
3722
+ }
3723
+ });
3724
+ // Store push subscription from a nerve endpoint
3725
+ app.post("/api/nerve/subscribe", async (c) => {
3726
+ const body = await c.req.json();
3727
+ const { subscription, label } = body;
3728
+ if (!subscription?.endpoint || !subscription?.keys) {
3729
+ return c.json({ error: "Invalid subscription" }, 400);
3730
+ }
3731
+ const id = await addSubscription(subscription, label);
3732
+ return c.json({ id });
3733
+ });
3734
+ // SSE stream — real-time nerve state updates
3735
+ app.get("/api/nerve/stream", async (c) => {
3736
+ return streamSSE(c, async (stream) => {
3737
+ // Send initial state
3738
+ const initial = await getNerveState();
3739
+ await stream.writeSSE({ event: "state", data: JSON.stringify(initial) });
3740
+ // Poll every 5 seconds and send updates
3741
+ let lastJson = JSON.stringify(initial);
3742
+ const interval = setInterval(async () => {
3743
+ try {
3744
+ const state = await getNerveState();
3745
+ const json = JSON.stringify(state);
3746
+ if (json !== lastJson) {
3747
+ lastJson = json;
3748
+ await stream.writeSSE({ event: "state", data: json });
3749
+ // Check if push notifications should fire
3750
+ await checkAndNotify(state).catch(() => { });
3751
+ }
3752
+ }
3753
+ catch { /* stream may be closed */ }
3754
+ }, 5000);
3755
+ // Keep alive
3756
+ const keepAlive = setInterval(async () => {
3757
+ try {
3758
+ await stream.writeSSE({ event: "ping", data: "" });
3759
+ }
3760
+ catch { /* ok */ }
3761
+ }, 30000);
3762
+ stream.onAbort(() => {
3763
+ clearInterval(interval);
3764
+ clearInterval(keepAlive);
3765
+ });
3766
+ // Hold the stream open
3767
+ await new Promise(() => { });
3768
+ });
3769
+ });
3770
+ // Update: accept a pending major update
3771
+ app.post("/api/nerve/accept-update", async (c) => {
3772
+ try {
3773
+ const { acceptMajorUpdate } = await import("./updater.js");
3774
+ const { clearPendingUpdate } = await import("./nerve/state.js");
3775
+ clearPendingUpdate();
3776
+ // This will restart the process after updating
3777
+ await acceptMajorUpdate();
3778
+ return c.json({ ok: true, message: "Updating and restarting..." });
3779
+ }
3780
+ catch (err) {
3781
+ return c.json({ error: err.message }, 500);
3782
+ }
3783
+ });
3396
3784
  // Chat: streamed response (or learn command)
3397
3785
  app.post("/api/chat", async (c) => {
3398
3786
  const body = await c.req.json();
@@ -4091,7 +4479,16 @@ app.post("/api/chat", async (c) => {
4091
4479
  ctx.messages[lastIdx] = { role: "user", content: userContent };
4092
4480
  }
4093
4481
  }
4094
- const stream_fn = pickStreamFn();
4482
+ let stream_fn;
4483
+ try {
4484
+ stream_fn = pickStreamFn();
4485
+ }
4486
+ catch (err) {
4487
+ if (err instanceof PrivateModeError) {
4488
+ return c.json({ error: err.message }, 503);
4489
+ }
4490
+ throw err;
4491
+ }
4095
4492
  const activeProvider = resolveProvider();
4096
4493
  const activeChatModel = resolveChatModel();
4097
4494
  const reqSignal = c.req.raw.signal;
@@ -4118,15 +4515,30 @@ app.post("/api/chat", async (c) => {
4118
4515
  resolve();
4119
4516
  };
4120
4517
  reqSignal?.addEventListener("abort", onAbort, { once: true });
4518
+ // Token buffer for split-placeholder rehydration
4519
+ let tokenBuf2 = "";
4520
+ const flushBuf2 = () => {
4521
+ if (!tokenBuf2)
4522
+ return;
4523
+ const rehydrated = rehydrateResponse(tokenBuf2);
4524
+ tokenBuf2 = "";
4525
+ stream.writeSSE({ data: JSON.stringify({ token: rehydrated }) }).catch(() => { });
4526
+ };
4121
4527
  stream_fn({
4122
4528
  messages: ctx.messages,
4123
4529
  model: activeChatModel,
4124
4530
  signal: reqSignal,
4125
4531
  onToken: (token) => {
4126
4532
  fullResponse += token;
4127
- stream.writeSSE({ data: JSON.stringify({ token }) }).catch(() => { });
4533
+ tokenBuf2 += token;
4534
+ // Hold if buffer ends with partial placeholder
4535
+ const lastOpen = tokenBuf2.lastIndexOf("<<");
4536
+ if (lastOpen !== -1 && tokenBuf2.indexOf(">>", lastOpen) === -1)
4537
+ return;
4538
+ flushBuf2();
4128
4539
  },
4129
4540
  onDone: () => {
4541
+ flushBuf2(); // flush remainder
4130
4542
  reqSignal?.removeEventListener("abort", onAbort);
4131
4543
  // Process action blocks BEFORE sending done — ensures SSE events reach client before stream closes.
4132
4544
  // ALWAYS strip [AGENT_REQUEST] blocks from response, even if they can't be parsed.
@@ -4347,14 +4759,20 @@ app.post("/api/chat", async (c) => {
4347
4759
  resolve();
4348
4760
  }
4349
4761
  },
4350
- onError: (err) => {
4762
+ onError: async (err) => {
4763
+ flushBuf2();
4351
4764
  reqSignal?.removeEventListener("abort", onAbort);
4352
4765
  // If this error is from an abort, save partial and exit quietly
4353
4766
  if (reqSignal?.aborted) {
4354
4767
  savePartial();
4355
4768
  }
4356
4769
  else {
4357
- const errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
4770
+ let errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
4771
+ if (isPrivateMode() && /ECONNREFUSED|fetch failed|network|socket/i.test(errorMsg)) {
4772
+ const health = await checkOllamaHealth();
4773
+ if (!health.ok)
4774
+ errorMsg += " — Check that Ollama is running. " + health.message;
4775
+ }
4358
4776
  stream.writeSSE({ data: JSON.stringify({ error: errorMsg }) }).catch(() => { });
4359
4777
  }
4360
4778
  resolve(); // Still resolve so stream closes
@@ -4375,6 +4793,13 @@ async function start() {
4375
4793
  });
4376
4794
  // Load settings (airplane mode, model selection)
4377
4795
  const settings = await loadSettings();
4796
+ // Install fetch guard before any routes or outbound calls
4797
+ installFetchGuard();
4798
+ // Initialize PrivacyMembrane for reversible redaction
4799
+ const sensitiveRegistry = new SensitiveRegistry();
4800
+ await sensitiveRegistry.load(BRAIN_DIR);
4801
+ const membrane = new PrivacyMembrane(sensitiveRegistry);
4802
+ setActiveMembrane(membrane);
4378
4803
  // Run independent initialization in parallel: LLM cache, auth, and sidecars
4379
4804
  const [, pairingCode, sidecarResults] = await Promise.all([
4380
4805
  // LLM response cache (file-backed in .core/cache/, 1-hour TTL)
@@ -4491,6 +4916,17 @@ async function start() {
4491
4916
  log.info(`Boot tension: ${todoCount} todo(s) found — pulse should fire`);
4492
4917
  }
4493
4918
  }
4919
+ // Initialize nerve push notifications (VAPID keys + subscriptions)
4920
+ try {
4921
+ await initPush();
4922
+ startPushMonitor(getNerveState, 30_000);
4923
+ log.info("Nerve push notifications ready (monitoring every 30s)");
4924
+ }
4925
+ catch (err) {
4926
+ log.warn("Nerve push init failed — push notifications disabled", {
4927
+ error: err instanceof Error ? err.message : String(err),
4928
+ });
4929
+ }
4494
4930
  // Start weekly backlog review timer (runs every Friday — DASH-59)
4495
4931
  startBacklogReviewTimer(queueProvider.getStore());
4496
4932
  // Start daily morning briefing timer (DASH-44)
@@ -4601,7 +5037,21 @@ async function start() {
4601
5037
  log.info(`${getInstanceName()} — Local Chat starting`);
4602
5038
  // Show LLM provider based on settings
4603
5039
  const provider = resolveProvider();
4604
- if (settings.airplaneMode) {
5040
+ if (settings.privateMode) {
5041
+ log.info("Mode: PRIVATE (network-isolated), LLM: Ollama only — cloud providers blocked");
5042
+ // Fail loudly at startup if Ollama isn't available in private mode
5043
+ try {
5044
+ const { assertOllamaAvailable } = await import("./llm/guard.js");
5045
+ await assertOllamaAvailable();
5046
+ log.info("Ollama: reachable ✓");
5047
+ }
5048
+ catch (err) {
5049
+ const msg = err instanceof Error ? err.message : String(err);
5050
+ log.error(msg);
5051
+ throw new Error("Cannot start in privateMode without Ollama. " + msg);
5052
+ }
5053
+ }
5054
+ else if (settings.airplaneMode) {
4605
5055
  log.info("Mode: Airplane (local only), LLM: Ollama");
4606
5056
  }
4607
5057
  else {
@@ -4767,10 +5217,46 @@ async function start() {
4767
5217
  return reply.trim();
4768
5218
  });
4769
5219
  log.info(`${getInstanceName()} email handler registered — emails with '${getInstanceName()}' in subject will be auto-replied`);
4770
- serve({ fetch: app.fetch, port: PORT }, () => {
4771
- log.info(`Listening on http://localhost:${PORT}`);
5220
+ await new Promise((resolve) => {
5221
+ const server = serve({ fetch: app.fetch, port: PORT }, () => {
5222
+ const addr = server.address();
5223
+ if (typeof addr === "object" && addr) {
5224
+ actualPort = addr.port;
5225
+ }
5226
+ log.info(`Listening on http://localhost:${actualPort}`);
5227
+ // Show LAN IP for phone access
5228
+ try {
5229
+ import("node:os").then(({ networkInterfaces }) => {
5230
+ const nets = networkInterfaces();
5231
+ for (const name of Object.keys(nets)) {
5232
+ for (const net of nets[name] ?? []) {
5233
+ if (net.family === "IPv4" && !net.internal) {
5234
+ log.info(`Nerve (phone): http://${net.address}:${actualPort}/nerve`);
5235
+ }
5236
+ }
5237
+ }
5238
+ });
5239
+ }
5240
+ catch { /* ok */ }
5241
+ // Announce on LAN if mesh.lanAnnounce is enabled
5242
+ if (getMeshConfig().lanAnnounce) {
5243
+ try {
5244
+ startMdns(actualPort);
5245
+ }
5246
+ catch (err) {
5247
+ log.warn("mDNS announcement failed — discovery disabled", {
5248
+ error: err instanceof Error ? err.message : String(err),
5249
+ });
5250
+ }
5251
+ }
5252
+ resolve();
5253
+ });
4772
5254
  });
4773
5255
  }
5256
+ /** Returns the port the server is actually listening on (resolves port 0). */
5257
+ export function getActualPort() {
5258
+ return actualPort;
5259
+ }
4774
5260
  export { start };
4775
5261
  const isDirectRun = process.argv[1]?.replace(/\\/g, "/").includes("server");
4776
5262
  if (isDirectRun) {
@@ -4779,6 +5265,87 @@ if (isDirectRun) {
4779
5265
  process.exit(1);
4780
5266
  });
4781
5267
  }
5268
+ function parseRoadmapYaml(raw) {
5269
+ const result = { phases: [], streams: [], milestones: [] };
5270
+ const lines = raw.split("\n");
5271
+ let section = null;
5272
+ let currentObj = null;
5273
+ function flush() {
5274
+ if (currentObj && section && section !== "layout") {
5275
+ result[section].push(currentObj);
5276
+ currentObj = null;
5277
+ }
5278
+ }
5279
+ for (const line of lines) {
5280
+ const trimmed = line.trimEnd();
5281
+ if (trimmed === "" || trimmed.startsWith("#"))
5282
+ continue;
5283
+ // Top-level section headers
5284
+ if (/^layout:\s*$/.test(trimmed)) {
5285
+ flush();
5286
+ section = "layout";
5287
+ result.layout = {};
5288
+ continue;
5289
+ }
5290
+ if (/^phases:\s*$/.test(trimmed)) {
5291
+ flush();
5292
+ section = "phases";
5293
+ continue;
5294
+ }
5295
+ if (/^streams:\s*$/.test(trimmed)) {
5296
+ flush();
5297
+ section = "streams";
5298
+ continue;
5299
+ }
5300
+ if (/^milestones:\s*$/.test(trimmed)) {
5301
+ flush();
5302
+ section = "milestones";
5303
+ continue;
5304
+ }
5305
+ if (!section)
5306
+ continue;
5307
+ // Layout is a flat object, not an array
5308
+ if (section === "layout") {
5309
+ const kvMatch = trimmed.match(/^\s+(\w+)\s*:\s*(.*)/);
5310
+ if (kvMatch && result.layout) {
5311
+ result.layout[kvMatch[1]] = kvMatch[2].replace(/^["']|["']$/g, "").trim();
5312
+ }
5313
+ continue;
5314
+ }
5315
+ // New list item: " - key: value"
5316
+ const newItemMatch = trimmed.match(/^\s+-\s+(\w+)\s*:\s*(.*)/);
5317
+ if (newItemMatch) {
5318
+ flush();
5319
+ currentObj = {};
5320
+ const key = newItemMatch[1];
5321
+ const val = newItemMatch[2].replace(/^["']|["']$/g, "").trim();
5322
+ if (val.startsWith("[")) {
5323
+ // Inline array: [a, b, c]
5324
+ currentObj[key] = val.slice(1, -1).split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
5325
+ }
5326
+ else {
5327
+ currentObj[key] = val;
5328
+ }
5329
+ continue;
5330
+ }
5331
+ // Continuation key: " key: value"
5332
+ if (currentObj) {
5333
+ const kvMatch = trimmed.match(/^\s+(\w+)\s*:\s*(.*)/);
5334
+ if (kvMatch) {
5335
+ const key = kvMatch[1];
5336
+ const val = kvMatch[2].replace(/^["']|["']$/g, "").trim();
5337
+ if (val.startsWith("[")) {
5338
+ currentObj[key] = val.slice(1, -1).split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
5339
+ }
5340
+ else {
5341
+ currentObj[key] = val;
5342
+ }
5343
+ }
5344
+ }
5345
+ }
5346
+ flush();
5347
+ return result;
5348
+ }
4782
5349
  // Graceful shutdown: agent pool first (drains queue, terminates agents, cleans resources),
4783
5350
  // then sidecars, timers, and monitors.
4784
5351
  async function gracefulShutdown(signal) {
@@ -4812,6 +5379,8 @@ async function gracefulShutdown(signal) {
4812
5379
  stopInsightsTimer();
4813
5380
  stopOpenLoopScanner();
4814
5381
  stopCreditMonitor();
5382
+ stopPushMonitor();
5383
+ stopMdns();
4815
5384
  stopAvatarSidecar();
4816
5385
  stopTtsSidecar();
4817
5386
  stopSttSidecar();