@runcore-sh/runcore 0.1.8 → 0.1.10

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 (225) 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 +261 -32
  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/files/deep-index.d.ts +59 -0
  49. package/dist/files/deep-index.d.ts.map +1 -0
  50. package/dist/files/deep-index.js +337 -0
  51. package/dist/files/deep-index.js.map +1 -0
  52. package/dist/files/import.d.ts +44 -0
  53. package/dist/files/import.d.ts.map +1 -0
  54. package/dist/files/import.js +213 -0
  55. package/dist/files/import.js.map +1 -0
  56. package/dist/files/index-local.d.ts +37 -0
  57. package/dist/files/index-local.d.ts.map +1 -0
  58. package/dist/files/index-local.js +198 -0
  59. package/dist/files/index-local.js.map +1 -0
  60. package/dist/google/auth.d.ts +2 -0
  61. package/dist/google/auth.d.ts.map +1 -1
  62. package/dist/google/auth.js +2 -0
  63. package/dist/google/auth.js.map +1 -1
  64. package/dist/integrations/gate.d.ts +40 -0
  65. package/dist/integrations/gate.d.ts.map +1 -0
  66. package/dist/integrations/gate.js +100 -0
  67. package/dist/integrations/gate.js.map +1 -0
  68. package/dist/lib/audit.d.ts +43 -0
  69. package/dist/lib/audit.d.ts.map +1 -0
  70. package/dist/lib/audit.js +120 -0
  71. package/dist/lib/audit.js.map +1 -0
  72. package/dist/lib/brain-io.d.ts.map +1 -1
  73. package/dist/lib/brain-io.js +52 -0
  74. package/dist/lib/brain-io.js.map +1 -1
  75. package/dist/lib/dpapi.d.ts +14 -0
  76. package/dist/lib/dpapi.d.ts.map +1 -0
  77. package/dist/lib/dpapi.js +104 -0
  78. package/dist/lib/dpapi.js.map +1 -0
  79. package/dist/lib/glob-match.d.ts +22 -0
  80. package/dist/lib/glob-match.d.ts.map +1 -0
  81. package/dist/lib/glob-match.js +64 -0
  82. package/dist/lib/glob-match.js.map +1 -0
  83. package/dist/lib/locked.d.ts +40 -0
  84. package/dist/lib/locked.d.ts.map +1 -0
  85. package/dist/lib/locked.js +130 -0
  86. package/dist/lib/locked.js.map +1 -0
  87. package/dist/llm/complete.d.ts.map +1 -1
  88. package/dist/llm/complete.js +5 -2
  89. package/dist/llm/complete.js.map +1 -1
  90. package/dist/llm/fetch-guard.d.ts +16 -0
  91. package/dist/llm/fetch-guard.d.ts.map +1 -0
  92. package/dist/llm/fetch-guard.js +61 -0
  93. package/dist/llm/fetch-guard.js.map +1 -0
  94. package/dist/llm/guard.d.ts +40 -0
  95. package/dist/llm/guard.d.ts.map +1 -0
  96. package/dist/llm/guard.js +88 -0
  97. package/dist/llm/guard.js.map +1 -0
  98. package/dist/llm/membrane.d.ts +46 -0
  99. package/dist/llm/membrane.d.ts.map +1 -0
  100. package/dist/llm/membrane.js +123 -0
  101. package/dist/llm/membrane.js.map +1 -0
  102. package/dist/llm/providers/index.d.ts +5 -1
  103. package/dist/llm/providers/index.d.ts.map +1 -1
  104. package/dist/llm/providers/index.js +8 -1
  105. package/dist/llm/providers/index.js.map +1 -1
  106. package/dist/llm/redact.d.ts +39 -0
  107. package/dist/llm/redact.d.ts.map +1 -0
  108. package/dist/llm/redact.js +155 -0
  109. package/dist/llm/redact.js.map +1 -0
  110. package/dist/llm/sensitive-registry.d.ts +33 -0
  111. package/dist/llm/sensitive-registry.d.ts.map +1 -0
  112. package/dist/llm/sensitive-registry.js +106 -0
  113. package/dist/llm/sensitive-registry.js.map +1 -0
  114. package/dist/mcp-server.d.ts +11 -0
  115. package/dist/mcp-server.d.ts.map +1 -0
  116. package/dist/mcp-server.js +520 -0
  117. package/dist/mcp-server.js.map +1 -0
  118. package/dist/mdns.d.ts +17 -0
  119. package/dist/mdns.d.ts.map +1 -0
  120. package/dist/mdns.js +110 -0
  121. package/dist/mdns.js.map +1 -0
  122. package/dist/nerve/push.d.ts +26 -0
  123. package/dist/nerve/push.d.ts.map +1 -0
  124. package/dist/nerve/push.js +170 -0
  125. package/dist/nerve/push.js.map +1 -0
  126. package/dist/nerve/state.d.ts +35 -0
  127. package/dist/nerve/state.d.ts.map +1 -0
  128. package/dist/nerve/state.js +257 -0
  129. package/dist/nerve/state.js.map +1 -0
  130. package/dist/posture/engine.d.ts +41 -0
  131. package/dist/posture/engine.d.ts.map +1 -0
  132. package/dist/posture/engine.js +217 -0
  133. package/dist/posture/engine.js.map +1 -0
  134. package/dist/posture/index.d.ts +11 -0
  135. package/dist/posture/index.d.ts.map +1 -0
  136. package/dist/posture/index.js +10 -0
  137. package/dist/posture/index.js.map +1 -0
  138. package/dist/posture/middleware.d.ts +30 -0
  139. package/dist/posture/middleware.d.ts.map +1 -0
  140. package/dist/posture/middleware.js +92 -0
  141. package/dist/posture/middleware.js.map +1 -0
  142. package/dist/posture/types.d.ts +61 -0
  143. package/dist/posture/types.d.ts.map +1 -0
  144. package/dist/posture/types.js +48 -0
  145. package/dist/posture/types.js.map +1 -0
  146. package/dist/resend/inbox.d.ts +23 -0
  147. package/dist/resend/inbox.d.ts.map +1 -0
  148. package/dist/resend/inbox.js +198 -0
  149. package/dist/resend/inbox.js.map +1 -0
  150. package/dist/resend/webhooks.d.ts +30 -0
  151. package/dist/resend/webhooks.d.ts.map +1 -0
  152. package/dist/resend/webhooks.js +244 -0
  153. package/dist/resend/webhooks.js.map +1 -0
  154. package/dist/server.d.ts +5 -1
  155. package/dist/server.d.ts.map +1 -1
  156. package/dist/server.js +773 -58
  157. package/dist/server.js.map +1 -1
  158. package/dist/settings.d.ts +14 -1
  159. package/dist/settings.d.ts.map +1 -1
  160. package/dist/settings.js +32 -1
  161. package/dist/settings.js.map +1 -1
  162. package/dist/tier/bond.d.ts +51 -0
  163. package/dist/tier/bond.d.ts.map +1 -0
  164. package/dist/tier/bond.js +154 -0
  165. package/dist/tier/bond.js.map +1 -0
  166. package/dist/tier/freeze.d.ts +21 -0
  167. package/dist/tier/freeze.d.ts.map +1 -0
  168. package/dist/tier/freeze.js +73 -0
  169. package/dist/tier/freeze.js.map +1 -0
  170. package/dist/tier/gate.d.ts +11 -0
  171. package/dist/tier/gate.d.ts.map +1 -0
  172. package/dist/tier/gate.js +25 -0
  173. package/dist/tier/gate.js.map +1 -0
  174. package/dist/tier/heartbeat.d.ts +22 -0
  175. package/dist/tier/heartbeat.d.ts.map +1 -0
  176. package/dist/tier/heartbeat.js +128 -0
  177. package/dist/tier/heartbeat.js.map +1 -0
  178. package/dist/tier/token.d.ts +22 -0
  179. package/dist/tier/token.d.ts.map +1 -0
  180. package/dist/tier/token.js +100 -0
  181. package/dist/tier/token.js.map +1 -0
  182. package/dist/tier/types.d.ts +44 -0
  183. package/dist/tier/types.d.ts.map +1 -0
  184. package/dist/tier/types.js +61 -0
  185. package/dist/tier/types.js.map +1 -0
  186. package/dist/updater.d.ts +32 -0
  187. package/dist/updater.d.ts.map +1 -0
  188. package/dist/updater.js +145 -0
  189. package/dist/updater.js.map +1 -0
  190. package/dist/vault/policy.d.ts +42 -0
  191. package/dist/vault/policy.d.ts.map +1 -0
  192. package/dist/vault/policy.js +159 -0
  193. package/dist/vault/policy.js.map +1 -0
  194. package/dist/vault/store.d.ts +6 -0
  195. package/dist/vault/store.d.ts.map +1 -1
  196. package/dist/vault/store.js +15 -5
  197. package/dist/vault/store.js.map +1 -1
  198. package/dist/vault/transfer.d.ts +33 -0
  199. package/dist/vault/transfer.d.ts.map +1 -0
  200. package/dist/vault/transfer.js +187 -0
  201. package/dist/vault/transfer.js.map +1 -0
  202. package/dist/voucher.d.ts +39 -0
  203. package/dist/voucher.d.ts.map +1 -0
  204. package/dist/voucher.js +105 -0
  205. package/dist/voucher.js.map +1 -0
  206. package/dist/webhooks/handlers.d.ts +10 -0
  207. package/dist/webhooks/handlers.d.ts.map +1 -1
  208. package/dist/webhooks/handlers.js +53 -0
  209. package/dist/webhooks/handlers.js.map +1 -1
  210. package/dist/webhooks/index.d.ts +2 -2
  211. package/dist/webhooks/index.d.ts.map +1 -1
  212. package/dist/webhooks/index.js +2 -2
  213. package/dist/webhooks/index.js.map +1 -1
  214. package/dist/webhooks/verify.d.ts +8 -0
  215. package/dist/webhooks/verify.d.ts.map +1 -1
  216. package/dist/webhooks/verify.js +56 -0
  217. package/dist/webhooks/verify.js.map +1 -1
  218. package/package.json +8 -2
  219. package/public/board.html +8 -3
  220. package/public/browser.html +8 -3
  221. package/public/library.html +8 -3
  222. package/public/observatory.html +8 -3
  223. package/public/ops.html +8 -3
  224. package/public/registry.html +627 -0
  225. 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 ---
@@ -384,12 +406,17 @@ app.use("/api/*", async (c, next) => {
384
406
  }
385
407
  return generalLimiter(c, next);
386
408
  });
409
+ // --- Posture middleware (intent accumulation + surface gating) ---
410
+ // Track all API interactions for posture engine
411
+ app.use("/api/*", postureTracker());
412
+ // Attach posture surface header to all responses
413
+ app.use("/api/*", postureHeader());
387
414
  // --- Webhook initialization (batch registration + config + admin routes) ---
388
415
  const webhookInitStart = performance.now();
389
416
  // Phase 1: Batch-register all webhook providers (deferred from module imports to avoid
390
417
  // 5 individual logActivity calls during startup — now a single batch call).
391
418
  const registerStart = performance.now();
392
- registerProviders([githubProvider, slackEventsProvider, slackCommandsProvider, slackInteractionsProvider, twilioProvider]);
419
+ registerProviders([githubProvider, slackEventsProvider, slackCommandsProvider, slackInteractionsProvider, twilioProvider, resendProvider]);
393
420
  const registerMs = performance.now() - registerStart;
394
421
  // Phase 2: Configure webhook providers (secrets resolved from env vars)
395
422
  const configStart = performance.now();
@@ -399,6 +426,7 @@ setProviderConfigs([
399
426
  { name: "slack-interactions", secret: "SLACK_SIGNING_SECRET", signatureHeader: "x-slack-signature", algorithm: "slack-v0", path: "/api/slack/interactions" },
400
427
  { name: "twilio", secret: "TWILIO_AUTH_TOKEN", signatureHeader: "x-twilio-signature", algorithm: "twilio", path: "/api/twilio/whatsapp" },
401
428
  { name: "github", secret: "GITHUB_WEBHOOK_SECRET", signatureHeader: "x-hub-signature-256", algorithm: "hmac-sha256-hex", path: "/api/github/webhooks" },
429
+ { name: "resend", secret: "RESEND_WEBHOOK_SECRET", signatureHeader: "svix-signature", algorithm: "custom", path: "/api/resend/webhooks" },
402
430
  ]);
403
431
  const configMs = performance.now() - configStart;
404
432
  // Phase 3: Mount admin routes
@@ -422,6 +450,7 @@ app.get("/api/status", async (c) => {
422
450
  ...status,
423
451
  provider: resolveProvider(),
424
452
  airplaneMode: settings.airplaneMode,
453
+ privateMode: settings.privateMode,
425
454
  safeWordMode: settings.safeWordMode,
426
455
  search: isSearchAvailable(),
427
456
  tts: isTtsAvailable(),
@@ -579,6 +608,70 @@ app.delete("/api/vault/:name", async (c) => {
579
608
  await deleteVaultKey(name, key);
580
609
  return c.json({ ok: true });
581
610
  });
611
+ // Export vault to portable passphrase-encrypted file
612
+ app.post("/api/vault/export", async (c) => {
613
+ const sessionId = c.req.query("sessionId");
614
+ if (!sessionId)
615
+ return c.json({ error: "sessionId required" }, 400);
616
+ const session = validateSession(sessionId);
617
+ if (!session)
618
+ return c.json({ error: "Invalid or expired session" }, 401);
619
+ const body = await c.req.json();
620
+ if (!body.passphrase || body.passphrase.length < 8) {
621
+ return c.json({ error: "Passphrase required (min 8 characters)" }, 400);
622
+ }
623
+ try {
624
+ const result = await exportVault(body.passphrase);
625
+ return c.json({ ok: true, filePath: result.filePath, stats: result.stats });
626
+ }
627
+ catch (e) {
628
+ return c.json({ error: e.message }, 500);
629
+ }
630
+ });
631
+ // Import vault from portable passphrase-encrypted file
632
+ app.post("/api/vault/import", async (c) => {
633
+ const sessionId = c.req.query("sessionId");
634
+ if (!sessionId)
635
+ return c.json({ error: "sessionId required" }, 400);
636
+ const session = validateSession(sessionId);
637
+ if (!session)
638
+ return c.json({ error: "Invalid or expired session" }, 401);
639
+ const key = sessionKeys.get(sessionId);
640
+ if (!key)
641
+ return c.json({ error: "Session key not found" }, 401);
642
+ const body = await c.req.json();
643
+ if (!body.filePath || !body.passphrase) {
644
+ return c.json({ error: "filePath and passphrase required" }, 400);
645
+ }
646
+ const strategy = body.strategy ?? "skip";
647
+ try {
648
+ const result = await importVault(body.filePath, body.passphrase, strategy, key);
649
+ return c.json({ ok: true, stats: result.stats });
650
+ }
651
+ catch (e) {
652
+ return c.json({ error: e.message }, 400);
653
+ }
654
+ });
655
+ // Verify a vault export file without importing
656
+ app.post("/api/vault/verify-export", async (c) => {
657
+ const sessionId = c.req.query("sessionId");
658
+ if (!sessionId)
659
+ return c.json({ error: "sessionId required" }, 400);
660
+ const session = validateSession(sessionId);
661
+ if (!session)
662
+ return c.json({ error: "Invalid or expired session" }, 401);
663
+ const body = await c.req.json();
664
+ if (!body.filePath || !body.passphrase) {
665
+ return c.json({ error: "filePath and passphrase required" }, 400);
666
+ }
667
+ try {
668
+ const result = await verifyExport(body.filePath, body.passphrase);
669
+ return c.json({ ok: true, message: result.message, stats: result.stats });
670
+ }
671
+ catch (e) {
672
+ return c.json({ error: e.message }, 400);
673
+ }
674
+ });
582
675
  // --- Google OAuth2 routes ---
583
676
  // Initiate Google OAuth flow — redirects to Google consent screen
584
677
  // No session required: this just redirects to Google, no sensitive data returned
@@ -1178,6 +1271,43 @@ app.put("/api/settings", async (c) => {
1178
1271
  },
1179
1272
  });
1180
1273
  });
1274
+ // --- Integration admin routes ---
1275
+ app.get("/api/admin/integrations", async (c) => {
1276
+ const settings = getSettings();
1277
+ const integrations = settings.integrations ?? { enabled: true };
1278
+ const status = getIntegrationStatus();
1279
+ return c.json({
1280
+ enabled: integrations.enabled ?? true,
1281
+ services: status,
1282
+ });
1283
+ });
1284
+ app.post("/api/admin/integrations", async (c) => {
1285
+ const body = await c.req.json();
1286
+ const patch = {
1287
+ integrations: {},
1288
+ };
1289
+ if (typeof body.enabled === "boolean") {
1290
+ patch.integrations.enabled = body.enabled;
1291
+ }
1292
+ if (body.services && typeof body.services === "object") {
1293
+ patch.integrations.services = body.services;
1294
+ }
1295
+ const updated = await updateSettings(patch);
1296
+ // Re-hydrate env with new gates — clear integration vars first, then re-hydrate
1297
+ const status = getIntegrationStatus();
1298
+ rehydrateVaultEnv();
1299
+ const credStore = getCredentialStore();
1300
+ if (credStore)
1301
+ await credStore.hydrate();
1302
+ return c.json({
1303
+ enabled: updated.integrations?.enabled ?? true,
1304
+ services: status.map((s) => ({
1305
+ ...s,
1306
+ // Re-check after settings update
1307
+ enabled: isIntegrationEnabled(s.service),
1308
+ })),
1309
+ });
1310
+ });
1181
1311
  // --- Voice routes ---
1182
1312
  // Voice status: which voice features are available?
1183
1313
  app.get("/api/voice-status", async (c) => {
@@ -1386,7 +1516,16 @@ app.post("/api/branch", async (c) => {
1386
1516
  ];
1387
1517
  const activeProvider = resolveProvider();
1388
1518
  const activeChatModel = resolveChatModel();
1389
- const stream_fn = pickStreamFn();
1519
+ let stream_fn;
1520
+ try {
1521
+ stream_fn = pickStreamFn();
1522
+ }
1523
+ catch (err) {
1524
+ if (err instanceof PrivateModeError) {
1525
+ return c.json({ error: err.message }, 503);
1526
+ }
1527
+ throw err;
1528
+ }
1390
1529
  const reqSignal = c.req.raw.signal;
1391
1530
  return streamSSE(c, async (stream) => {
1392
1531
  // Send branch trace metadata so the UI can track lineage
@@ -1407,21 +1546,42 @@ app.post("/api/branch", async (c) => {
1407
1546
  }
1408
1547
  const onAbort = () => resolve();
1409
1548
  reqSignal?.addEventListener("abort", onAbort, { once: true });
1549
+ // Token buffer for split-placeholder rehydration
1550
+ let tokenBuf = "";
1551
+ const flushBuf = () => {
1552
+ if (!tokenBuf)
1553
+ return;
1554
+ const rehydrated = rehydrateResponse(tokenBuf);
1555
+ tokenBuf = "";
1556
+ stream.writeSSE({ data: JSON.stringify({ token: rehydrated }) }).catch(() => { });
1557
+ };
1410
1558
  stream_fn({
1411
1559
  messages,
1412
1560
  model: activeChatModel,
1413
1561
  signal: reqSignal,
1414
1562
  onToken: (token) => {
1415
- stream.writeSSE({ data: JSON.stringify({ token }) }).catch(() => { });
1563
+ tokenBuf += token;
1564
+ // Hold if buffer ends with partial placeholder: << ... (no closing >>)
1565
+ const lastOpen = tokenBuf.lastIndexOf("<<");
1566
+ if (lastOpen !== -1 && tokenBuf.indexOf(">>", lastOpen) === -1)
1567
+ return;
1568
+ flushBuf();
1416
1569
  },
1417
1570
  onDone: () => {
1571
+ flushBuf(); // flush remainder
1418
1572
  reqSignal?.removeEventListener("abort", onAbort);
1419
1573
  stream.writeSSE({ data: JSON.stringify({ done: true }) }).catch(() => { });
1420
1574
  resolve();
1421
1575
  },
1422
- onError: (err) => {
1576
+ onError: async (err) => {
1577
+ flushBuf();
1423
1578
  reqSignal?.removeEventListener("abort", onAbort);
1424
- const errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
1579
+ let errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
1580
+ if (isPrivateMode() && /ECONNREFUSED|fetch failed|network|socket/i.test(errorMsg)) {
1581
+ const health = await checkOllamaHealth();
1582
+ if (!health.ok)
1583
+ errorMsg += " — Check that Ollama is running. " + health.message;
1584
+ }
1425
1585
  stream.writeSSE({ data: JSON.stringify({ error: errorMsg }) }).catch(() => { });
1426
1586
  resolve();
1427
1587
  },
@@ -2076,6 +2236,22 @@ app.post("/api/slack/commands", createWebhookRoute({ provider: "slack-commands"
2076
2236
  // Slack interactions: routed through the generic webhook system.
2077
2237
  // The slack-interactions provider handles extracting JSON from the form "payload" field.
2078
2238
  app.post("/api/slack/interactions", createWebhookRoute({ provider: "slack-interactions" }));
2239
+ // --- Resend inbound email ---
2240
+ // Resend webhook: receive inbound emails via Svix-signed webhooks (direct path).
2241
+ // Uses generic webhook route with Svix signature verification.
2242
+ app.post("/api/resend/webhooks", createWebhookRoute({ provider: "resend" }));
2243
+ // Resend inbox: manually trigger inbox check (pulls from Worker KV).
2244
+ app.post("/api/resend/check-inbox", async (c) => {
2245
+ const sessionId = c.req.query("sessionId");
2246
+ if (!sessionId)
2247
+ return c.json({ error: "sessionId required" }, 400);
2248
+ const session = validateSession(sessionId);
2249
+ if (!session)
2250
+ return c.json({ error: "Invalid or expired session" }, 401);
2251
+ const { forceCheckResendInbox } = await import("./resend/inbox.js");
2252
+ const count = await forceCheckResendInbox();
2253
+ return c.json({ ok: true, processed: count });
2254
+ });
2079
2255
  // --- WhatsApp routes (Twilio-backed) ---
2080
2256
  // WhatsApp status: check if configured
2081
2257
  app.get("/api/whatsapp/status", (c) => {
@@ -2147,6 +2323,59 @@ app.post("/api/relay/whatsapp", async (c) => {
2147
2323
  const result = await handleWhatsAppMessage(from, body, payload.ProfileName);
2148
2324
  return c.json({ ok: result.ok, reply: result.reply, error: result.error });
2149
2325
  });
2326
+ // Resend relay: receive pre-verified inbound emails from the Cloudflare Worker.
2327
+ // The Worker has already verified Resend's Svix signature and fetched the full
2328
+ // email body — this endpoint verifies the relay's own HMAC-SHA256 signature.
2329
+ app.post("/api/relay/resend", async (c) => {
2330
+ const rawBody = await c.req.text();
2331
+ const headers = {};
2332
+ c.req.raw.headers.forEach((value, key) => {
2333
+ headers[key.toLowerCase()] = value;
2334
+ });
2335
+ const relaySecret = process.env.RELAY_SECRET ?? "";
2336
+ const verification = verifyRelaySignature(rawBody, headers, relaySecret);
2337
+ if (!verification.valid) {
2338
+ return c.json({ error: verification.error ?? "Invalid relay signature" }, 401);
2339
+ }
2340
+ let payload;
2341
+ try {
2342
+ payload = JSON.parse(rawBody);
2343
+ }
2344
+ catch {
2345
+ return c.json({ error: "Invalid JSON" }, 400);
2346
+ }
2347
+ if (payload.type !== "email.received" || !payload.body?.trim()) {
2348
+ return c.json({ ok: true, message: "No actionable content" });
2349
+ }
2350
+ logActivity({
2351
+ source: "resend",
2352
+ summary: `Inbound email relayed from ${payload.from}: "${payload.subject}"`,
2353
+ });
2354
+ // Import processInboundEmail dynamically to avoid circular deps at module level
2355
+ const { processInboundEmail, sendResendReply } = await import("./resend/webhooks.js");
2356
+ const reply = await processInboundEmail({
2357
+ from: payload.from,
2358
+ subject: payload.subject,
2359
+ body: payload.body,
2360
+ date: payload.created_at || new Date().toISOString(),
2361
+ });
2362
+ if (!reply) {
2363
+ return c.json({ ok: true, message: "No reply generated" });
2364
+ }
2365
+ const sent = await sendResendReply({
2366
+ to: payload.from,
2367
+ subject: payload.subject,
2368
+ body: reply,
2369
+ inReplyTo: payload.message_id,
2370
+ });
2371
+ logActivity({
2372
+ source: "resend",
2373
+ summary: sent
2374
+ ? `Replied to ${payload.from}: "${payload.subject}" (${reply.length} chars)`
2375
+ : `Failed to reply to ${payload.from}: "${payload.subject}"`,
2376
+ });
2377
+ return c.json({ ok: true, sent, replyLength: reply.length });
2378
+ });
2150
2379
  // WhatsApp send: send a message (requires sessionId)
2151
2380
  app.post("/api/whatsapp/send", async (c) => {
2152
2381
  const sessionId = c.req.query("sessionId");
@@ -2214,6 +2443,10 @@ function issuesToCardPayload(issues) {
2214
2443
  };
2215
2444
  });
2216
2445
  }
2446
+ // Board API — gated to board posture
2447
+ app.use("/api/board/*", requireSurface("pages"));
2448
+ app.use("/api/ops/*", requireSurface("pages"));
2449
+ app.use("/api/agents/*", requireSurface("agents"));
2217
2450
  // Board status: is a provider configured, and who is the user?
2218
2451
  app.get("/api/board/status", async (c) => {
2219
2452
  const board = getBoardProvider();
@@ -3020,32 +3253,114 @@ app.get("/api/help/context", async (c) => {
3020
3253
  changelog,
3021
3254
  });
3022
3255
  });
3023
- // --- Ops dashboard routes (no auth — local-only diagnostics) ---
3024
- // Serve observatory.html
3025
- app.get("/observatory", async (c) => {
3256
+ // --- Ops dashboard routes (posture-gated: board level) ---
3257
+ // Board-level pages — only assembled when user has shown intent for full visibility
3258
+ app.get("/observatory", requireSurface("pages"), async (c) => {
3026
3259
  const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "observatory.html"));
3027
3260
  return c.html(html);
3028
3261
  });
3029
- // Serve ops.html
3030
- app.get("/ops", async (c) => {
3262
+ app.get("/ops", requireSurface("pages"), async (c) => {
3031
3263
  const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "ops.html"));
3032
3264
  return c.html(html);
3033
3265
  });
3034
- // Serve board.html (kanban view)
3035
- app.get("/board", async (c) => {
3266
+ app.get("/board", requireSurface("pages"), async (c) => {
3036
3267
  const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "board.html"));
3037
3268
  return c.html(html);
3038
3269
  });
3039
- // Serve library.html (file explorer)
3040
- app.get("/library", async (c) => {
3270
+ app.get("/library", requireSurface("pages"), async (c) => {
3041
3271
  const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "library.html"));
3042
3272
  return c.html(html);
3043
3273
  });
3044
- // Serve browser.html (agent's-eye view of web pages)
3045
- app.get("/browser", async (c) => {
3274
+ app.get("/browser", requireSurface("pages"), async (c) => {
3046
3275
  const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "browser.html"));
3047
3276
  return c.html(html);
3048
3277
  });
3278
+ // Registry is always available — it's the entry point
3279
+ app.get("/registry", async (c) => {
3280
+ const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "registry.html"));
3281
+ return c.html(html);
3282
+ });
3283
+ // Serve roadmap.html (strategic roadmap & rearview)
3284
+ app.get("/roadmap", async (c) => {
3285
+ const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "roadmap.html"));
3286
+ return c.html(html);
3287
+ });
3288
+ // Roadmap API — parse brain/operations/roadmap.yaml and return as JSON
3289
+ app.get("/api/roadmap", async (c) => {
3290
+ try {
3291
+ const raw = await readBrainFile(join(process.cwd(), "brain", "operations", "roadmap.yaml"));
3292
+ const parsed = parseRoadmapYaml(raw);
3293
+ return c.json(parsed);
3294
+ }
3295
+ catch (err) {
3296
+ return c.json({ error: "Failed to load roadmap.yaml" }, 500);
3297
+ }
3298
+ });
3299
+ // Roadmap rearview API — recent git commits grouped by hour
3300
+ app.get("/api/roadmap/recent", async (c) => {
3301
+ const hours = parseInt(c.req.query("hours") || "24", 10);
3302
+ if (isNaN(hours) || hours < 1 || hours > 168) {
3303
+ return c.json({ error: "hours must be between 1 and 168" }, 400);
3304
+ }
3305
+ try {
3306
+ const { execSync } = await import("child_process");
3307
+ const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
3308
+ const raw = execSync(`git log --after="${since}" --format="%H||%an||%ai||%s" --no-merges`, { cwd: process.cwd(), encoding: "utf-8", timeout: 10000 }).trim();
3309
+ if (!raw)
3310
+ return c.json({ commits: [], groups: [], hours, total: 0 });
3311
+ const commits = raw.split("\n").map((line) => {
3312
+ const [hash, author, date, ...msgParts] = line.split("||");
3313
+ const message = msgParts.join("||");
3314
+ const isAuto = /^\[(?:dash|agent)\]/i.test(message);
3315
+ return {
3316
+ hash: hash.slice(0, 8),
3317
+ fullHash: hash,
3318
+ author,
3319
+ date,
3320
+ message,
3321
+ autonomous: isAuto,
3322
+ tag: isAuto ? "dash" : "human",
3323
+ };
3324
+ });
3325
+ // Group by hour bucket
3326
+ const groups = {};
3327
+ for (const commit of commits) {
3328
+ const d = new Date(commit.date);
3329
+ const hourKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:00`;
3330
+ if (!groups[hourKey])
3331
+ groups[hourKey] = [];
3332
+ groups[hourKey].push(commit);
3333
+ }
3334
+ const grouped = Object.entries(groups)
3335
+ .map(([hour, items]) => ({ hour, commits: items, count: items.length }))
3336
+ .sort((a, b) => b.hour.localeCompare(a.hour));
3337
+ return c.json({ commits, groups: grouped, hours, total: commits.length });
3338
+ }
3339
+ catch (err) {
3340
+ const msg = err instanceof Error ? err.message : String(err);
3341
+ return c.json({ error: "Failed to read git log: " + msg }, 500);
3342
+ }
3343
+ });
3344
+ // Serve personal.html (placeholder — instances populate this)
3345
+ app.get("/personal", async (c) => {
3346
+ try {
3347
+ const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "personal.html"));
3348
+ return c.html(html);
3349
+ }
3350
+ catch {
3351
+ return c.text("Personal page not configured for this instance.", 404);
3352
+ }
3353
+ });
3354
+ // Serve life.html (placeholder — instances populate this)
3355
+ app.get("/life", async (c) => {
3356
+ try {
3357
+ const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "life.html"));
3358
+ return c.html(html);
3359
+ }
3360
+ catch {
3361
+ return c.text("Life page not configured for this instance.", 404);
3362
+ }
3363
+ });
3049
3364
  // Browse API — fetch a URL and return what the agent sees (stripped text)
3050
3365
  app.get("/api/browse", async (c) => {
3051
3366
  const url = c.req.query("url");
@@ -3159,6 +3474,7 @@ app.get("/api/ops/health", async (c) => {
3159
3474
  },
3160
3475
  provider: resolveProvider(),
3161
3476
  airplaneMode: settings.airplaneMode,
3477
+ privateMode: settings.privateMode,
3162
3478
  models: settings.models,
3163
3479
  sidecars: {
3164
3480
  search: isSidecarAvailable(),
@@ -3378,6 +3694,30 @@ app.delete("/api/ops/projects/:id", async (c) => {
3378
3694
  return c.json({ error: "Project not found" }, 404);
3379
3695
  return c.json({ ok: true });
3380
3696
  });
3697
+ // --- Posture API (UI surface assembly) ---
3698
+ app.get("/api/posture", (c) => {
3699
+ return c.json({
3700
+ posture: getPosture(),
3701
+ surface: getSurface(),
3702
+ state: getPostureState(),
3703
+ });
3704
+ });
3705
+ app.put("/api/posture", async (c) => {
3706
+ const body = await c.req.json();
3707
+ if (body.posture && ["silent", "pulse", "board"].includes(body.posture)) {
3708
+ if (body.pinned !== false) {
3709
+ pinPosture(body.posture);
3710
+ }
3711
+ else {
3712
+ pinPosture(body.posture);
3713
+ unpinPosture();
3714
+ }
3715
+ }
3716
+ else if (body.pinned === false) {
3717
+ unpinPosture();
3718
+ }
3719
+ return c.json({ posture: getPosture(), surface: getSurface(), state: getPostureState() });
3720
+ });
3381
3721
  // --- Pulse (nervous system) endpoint ---
3382
3722
  app.get("/api/pulse/status", (c) => {
3383
3723
  const integrator = getPressureIntegrator();
@@ -3393,6 +3733,85 @@ app.get("/api/pulse/history", (c) => {
3393
3733
  }
3394
3734
  return c.json(integrator.getVoltageHistory());
3395
3735
  });
3736
+ // --- Nerve API (three-dot goo) ---
3737
+ import { getNerveState } from "./nerve/state.js";
3738
+ import { initPush, getVapidPublicKey, addSubscription, checkAndNotify, startPushMonitor, stopPushMonitor } from "./nerve/push.js";
3739
+ import { loadPosture, startDecayTimer, getPosture, getPostureState, getSurface, pinPosture, unpinPosture, } from "./posture/engine.js";
3740
+ import { postureTracker, requireSurface, postureHeader } from "./posture/middleware.js";
3741
+ // State endpoint — three dots
3742
+ app.get("/api/nerve/state", async (c) => {
3743
+ const state = await getNerveState();
3744
+ return c.json(state);
3745
+ });
3746
+ // VAPID public key for push subscription
3747
+ app.get("/api/nerve/vapid-key", (c) => {
3748
+ try {
3749
+ return c.json({ key: getVapidPublicKey() });
3750
+ }
3751
+ catch {
3752
+ return c.json({ error: "Push not initialized" }, 503);
3753
+ }
3754
+ });
3755
+ // Store push subscription from a nerve endpoint
3756
+ app.post("/api/nerve/subscribe", async (c) => {
3757
+ const body = await c.req.json();
3758
+ const { subscription, label } = body;
3759
+ if (!subscription?.endpoint || !subscription?.keys) {
3760
+ return c.json({ error: "Invalid subscription" }, 400);
3761
+ }
3762
+ const id = await addSubscription(subscription, label);
3763
+ return c.json({ id });
3764
+ });
3765
+ // SSE stream — real-time nerve state updates
3766
+ app.get("/api/nerve/stream", async (c) => {
3767
+ return streamSSE(c, async (stream) => {
3768
+ // Send initial state
3769
+ const initial = await getNerveState();
3770
+ await stream.writeSSE({ event: "state", data: JSON.stringify(initial) });
3771
+ // Poll every 5 seconds and send updates
3772
+ let lastJson = JSON.stringify(initial);
3773
+ const interval = setInterval(async () => {
3774
+ try {
3775
+ const state = await getNerveState();
3776
+ const json = JSON.stringify(state);
3777
+ if (json !== lastJson) {
3778
+ lastJson = json;
3779
+ await stream.writeSSE({ event: "state", data: json });
3780
+ // Check if push notifications should fire
3781
+ await checkAndNotify(state).catch(() => { });
3782
+ }
3783
+ }
3784
+ catch { /* stream may be closed */ }
3785
+ }, 5000);
3786
+ // Keep alive
3787
+ const keepAlive = setInterval(async () => {
3788
+ try {
3789
+ await stream.writeSSE({ event: "ping", data: "" });
3790
+ }
3791
+ catch { /* ok */ }
3792
+ }, 30000);
3793
+ stream.onAbort(() => {
3794
+ clearInterval(interval);
3795
+ clearInterval(keepAlive);
3796
+ });
3797
+ // Hold the stream open
3798
+ await new Promise(() => { });
3799
+ });
3800
+ });
3801
+ // Update: accept a pending major update
3802
+ app.post("/api/nerve/accept-update", async (c) => {
3803
+ try {
3804
+ const { acceptMajorUpdate } = await import("./updater.js");
3805
+ const { clearPendingUpdate } = await import("./nerve/state.js");
3806
+ clearPendingUpdate();
3807
+ // This will restart the process after updating
3808
+ await acceptMajorUpdate();
3809
+ return c.json({ ok: true, message: "Updating and restarting..." });
3810
+ }
3811
+ catch (err) {
3812
+ return c.json({ error: err.message }, 500);
3813
+ }
3814
+ });
3396
3815
  // Chat: streamed response (or learn command)
3397
3816
  app.post("/api/chat", async (c) => {
3398
3817
  const body = await c.req.json();
@@ -4091,7 +4510,16 @@ app.post("/api/chat", async (c) => {
4091
4510
  ctx.messages[lastIdx] = { role: "user", content: userContent };
4092
4511
  }
4093
4512
  }
4094
- const stream_fn = pickStreamFn();
4513
+ let stream_fn;
4514
+ try {
4515
+ stream_fn = pickStreamFn();
4516
+ }
4517
+ catch (err) {
4518
+ if (err instanceof PrivateModeError) {
4519
+ return c.json({ error: err.message }, 503);
4520
+ }
4521
+ throw err;
4522
+ }
4095
4523
  const activeProvider = resolveProvider();
4096
4524
  const activeChatModel = resolveChatModel();
4097
4525
  const reqSignal = c.req.raw.signal;
@@ -4118,15 +4546,30 @@ app.post("/api/chat", async (c) => {
4118
4546
  resolve();
4119
4547
  };
4120
4548
  reqSignal?.addEventListener("abort", onAbort, { once: true });
4549
+ // Token buffer for split-placeholder rehydration
4550
+ let tokenBuf2 = "";
4551
+ const flushBuf2 = () => {
4552
+ if (!tokenBuf2)
4553
+ return;
4554
+ const rehydrated = rehydrateResponse(tokenBuf2);
4555
+ tokenBuf2 = "";
4556
+ stream.writeSSE({ data: JSON.stringify({ token: rehydrated }) }).catch(() => { });
4557
+ };
4121
4558
  stream_fn({
4122
4559
  messages: ctx.messages,
4123
4560
  model: activeChatModel,
4124
4561
  signal: reqSignal,
4125
4562
  onToken: (token) => {
4126
4563
  fullResponse += token;
4127
- stream.writeSSE({ data: JSON.stringify({ token }) }).catch(() => { });
4564
+ tokenBuf2 += token;
4565
+ // Hold if buffer ends with partial placeholder
4566
+ const lastOpen = tokenBuf2.lastIndexOf("<<");
4567
+ if (lastOpen !== -1 && tokenBuf2.indexOf(">>", lastOpen) === -1)
4568
+ return;
4569
+ flushBuf2();
4128
4570
  },
4129
4571
  onDone: () => {
4572
+ flushBuf2(); // flush remainder
4130
4573
  reqSignal?.removeEventListener("abort", onAbort);
4131
4574
  // Process action blocks BEFORE sending done — ensures SSE events reach client before stream closes.
4132
4575
  // ALWAYS strip [AGENT_REQUEST] blocks from response, even if they can't be parsed.
@@ -4347,14 +4790,20 @@ app.post("/api/chat", async (c) => {
4347
4790
  resolve();
4348
4791
  }
4349
4792
  },
4350
- onError: (err) => {
4793
+ onError: async (err) => {
4794
+ flushBuf2();
4351
4795
  reqSignal?.removeEventListener("abort", onAbort);
4352
4796
  // If this error is from an abort, save partial and exit quietly
4353
4797
  if (reqSignal?.aborted) {
4354
4798
  savePartial();
4355
4799
  }
4356
4800
  else {
4357
- const errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
4801
+ let errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
4802
+ if (isPrivateMode() && /ECONNREFUSED|fetch failed|network|socket/i.test(errorMsg)) {
4803
+ const health = await checkOllamaHealth();
4804
+ if (!health.ok)
4805
+ errorMsg += " — Check that Ollama is running. " + health.message;
4806
+ }
4358
4807
  stream.writeSSE({ data: JSON.stringify({ error: errorMsg }) }).catch(() => { });
4359
4808
  }
4360
4809
  resolve(); // Still resolve so stream closes
@@ -4363,8 +4812,111 @@ app.post("/api/chat", async (c) => {
4363
4812
  });
4364
4813
  });
4365
4814
  });
4815
+ // --- Freeze endpoint (operator clicks freeze link) ---
4816
+ app.post("/api/freeze", async (c) => {
4817
+ const body = await c.req.json().catch(() => null);
4818
+ if (!body?.signal)
4819
+ return c.json({ error: "Missing freeze signal" }, 400);
4820
+ const { freeze, isFrozen } = await import("./tier/freeze.js");
4821
+ if (isFrozen())
4822
+ return c.json({ error: "Already frozen" }, 409);
4823
+ await freeze(body.signal, process.cwd());
4824
+ return c.json({ status: "frozen", message: "All agents dormant." });
4825
+ });
4826
+ app.post("/api/thaw", async (c) => {
4827
+ const { thaw } = await import("./tier/freeze.js");
4828
+ await thaw(process.cwd());
4829
+ return c.json({ status: "thawed", message: "Operations resuming." });
4830
+ });
4831
+ app.get("/api/freeze/status", async (c) => {
4832
+ const { isFrozen, getFreezeSignal } = await import("./tier/freeze.js");
4833
+ return c.json({ frozen: isFrozen(), signal: getFreezeSignal() });
4834
+ });
4835
+ // --- Brain import API ---
4836
+ app.post("/api/import/folder", async (c) => {
4837
+ const body = await c.req.json();
4838
+ if (!body.paths || !Array.isArray(body.paths) || body.paths.length === 0) {
4839
+ return c.json({ error: "paths[] required" }, 400);
4840
+ }
4841
+ const { resolve } = await import("node:path");
4842
+ const { importToBrain } = await import("./files/import.js");
4843
+ const result = await importToBrain({
4844
+ sources: body.paths.map(p => resolve(p)),
4845
+ brainRoot: process.cwd(),
4846
+ dryRun: body.dryRun ?? false,
4847
+ });
4848
+ // After import: fast pass → deep pass (both background, chained)
4849
+ if (!body.dryRun && result.imported > 0) {
4850
+ import("./files/index-local.js").then(({ indexImportedFiles }) => {
4851
+ indexImportedFiles({ localOnly: true })
4852
+ .then(() => import("./files/deep-index.js"))
4853
+ .then(({ runDeepIndex }) => runDeepIndex())
4854
+ .catch(() => { });
4855
+ });
4856
+ }
4857
+ return c.json(result);
4858
+ });
4859
+ app.post("/api/import/index", async (c) => {
4860
+ const { indexImportedFiles } = await import("./files/index-local.js");
4861
+ const result = await indexImportedFiles({ localOnly: true });
4862
+ // After fast pass, kick off deep index in background
4863
+ import("./files/deep-index.js").then(({ runDeepIndex }) => {
4864
+ runDeepIndex().catch(() => { });
4865
+ });
4866
+ return c.json(result);
4867
+ });
4868
+ app.post("/api/import/deep-index", async (c) => {
4869
+ const { runDeepIndex } = await import("./files/deep-index.js");
4870
+ const result = await runDeepIndex();
4871
+ return c.json(result);
4872
+ });
4873
+ app.get("/api/import/deep-index/progress", async (c) => {
4874
+ const { getDeepIndexProgress } = await import("./files/deep-index.js");
4875
+ return c.json(getDeepIndexProgress());
4876
+ });
4877
+ app.get("/api/import/deep-index/results", async (c) => {
4878
+ const { readFile: rf } = await import("node:fs/promises");
4879
+ const { join: jp } = await import("node:path");
4880
+ try {
4881
+ const raw = await rf(jp(process.cwd(), "brain", ".core", "deep-index.json"), "utf-8");
4882
+ return c.json(JSON.parse(raw));
4883
+ }
4884
+ catch {
4885
+ return c.json({ entities: [], themes: [], crossRefs: [], flags: [], deepIndexed: [] });
4886
+ }
4887
+ });
4888
+ app.post("/api/import/files", async (c) => {
4889
+ const formData = await c.req.formData();
4890
+ const files = formData.getAll("files");
4891
+ if (files.length === 0)
4892
+ return c.json({ error: "No files provided" }, 400);
4893
+ const { mkdir: mkdirFs, writeFile: writeFs } = await import("node:fs/promises");
4894
+ const { join: joinPath } = await import("node:path");
4895
+ const ingestDir = joinPath(process.cwd(), "ingest");
4896
+ await mkdirFs(ingestDir, { recursive: true });
4897
+ const saved = [];
4898
+ for (const file of files) {
4899
+ if (!file.name)
4900
+ continue;
4901
+ const buffer = Buffer.from(await file.arrayBuffer());
4902
+ const dest = joinPath(ingestDir, file.name);
4903
+ await writeFs(dest, buffer);
4904
+ saved.push(file.name);
4905
+ }
4906
+ // Process the ingest folder immediately
4907
+ const { processIngestFolder } = await import("./files/ingest-folder.js");
4908
+ const ingestedDir = joinPath(process.cwd(), "ingested");
4909
+ const result = await processIngestFolder(ingestDir, ingestedDir);
4910
+ return c.json({
4911
+ saved: saved.length,
4912
+ files: saved,
4913
+ ingested: result.newFiles,
4914
+ });
4915
+ });
4366
4916
  // --- Startup ---
4367
- async function start() {
4917
+ async function start(opts) {
4918
+ const tier = opts?.tier ?? "byok";
4919
+ const tierGate = await import("./tier/gate.js");
4368
4920
  // Initialize instance name before anything else
4369
4921
  initInstanceName();
4370
4922
  // Initialize OpenTelemetry tracing (must be early, before instrumented code)
@@ -4375,6 +4927,16 @@ async function start() {
4375
4927
  });
4376
4928
  // Load settings (airplane mode, model selection)
4377
4929
  const settings = await loadSettings();
4930
+ // Initialize posture system (UI surface assembly)
4931
+ await loadPosture();
4932
+ startDecayTimer();
4933
+ // Install fetch guard before any routes or outbound calls
4934
+ installFetchGuard();
4935
+ // Initialize PrivacyMembrane for reversible redaction
4936
+ const sensitiveRegistry = new SensitiveRegistry();
4937
+ await sensitiveRegistry.load(BRAIN_DIR);
4938
+ const membrane = new PrivacyMembrane(sensitiveRegistry);
4939
+ setActiveMembrane(membrane);
4378
4940
  // Run independent initialization in parallel: LLM cache, auth, and sidecars
4379
4941
  const [, pairingCode, sidecarResults] = await Promise.all([
4380
4942
  // LLM response cache (file-backed in .core/cache/, 1-hour TTL)
@@ -4491,6 +5053,17 @@ async function start() {
4491
5053
  log.info(`Boot tension: ${todoCount} todo(s) found — pulse should fire`);
4492
5054
  }
4493
5055
  }
5056
+ // Initialize nerve push notifications (VAPID keys + subscriptions)
5057
+ try {
5058
+ await initPush();
5059
+ startPushMonitor(getNerveState, 30_000);
5060
+ log.info("Nerve push notifications ready (monitoring every 30s)");
5061
+ }
5062
+ catch (err) {
5063
+ log.warn("Nerve push init failed — push notifications disabled", {
5064
+ error: err instanceof Error ? err.message : String(err),
5065
+ });
5066
+ }
4494
5067
  // Start weekly backlog review timer (runs every Friday — DASH-59)
4495
5068
  startBacklogReviewTimer(queueProvider.getStore());
4496
5069
  // Start daily morning briefing timer (DASH-44)
@@ -4527,17 +5100,24 @@ async function start() {
4527
5100
  logActivity({ source: "agent", summary: `Continuation error: ${msg}` });
4528
5101
  }
4529
5102
  });
4530
- // Initialize instance manager (GC, health checks, load balancing)
4531
- instanceManager = new AgentInstanceManager(runtime);
4532
- await instanceManager.init();
4533
- // Initialize agent pool (circuit breakers, isolation, resource management)
4534
- agentPool = AgentPool.fromExisting(runtime, instanceManager);
4535
- setAgentPool(agentPool);
4536
- // Initialize workflow engine for multi-agent coordination
4537
- workflowEngine = new WorkflowEngine(agentPool);
4538
- await workflowEngine.loadAllDefinitions().catch((err) => {
4539
- log.warn("Failed to load workflow definitions", { error: err instanceof Error ? err.message : String(err) });
4540
- });
5103
+ // Initialize agent spawning tier >= spawn only
5104
+ if (tierGate.canSpawn(tier)) {
5105
+ // Initialize instance manager (GC, health checks, load balancing)
5106
+ instanceManager = new AgentInstanceManager(runtime);
5107
+ await instanceManager.init();
5108
+ // Initialize agent pool (circuit breakers, isolation, resource management)
5109
+ agentPool = AgentPool.fromExisting(runtime, instanceManager);
5110
+ setAgentPool(agentPool);
5111
+ // Initialize workflow engine for multi-agent coordination
5112
+ workflowEngine = new WorkflowEngine(agentPool);
5113
+ await workflowEngine.loadAllDefinitions().catch((err) => {
5114
+ log.warn("Failed to load workflow definitions", { error: err instanceof Error ? err.message : String(err) });
5115
+ });
5116
+ log.info(`Agent spawning enabled (tier: ${tier})`);
5117
+ }
5118
+ else {
5119
+ log.info(`Agent spawning disabled (tier: ${tier} — requires spawn tier)`);
5120
+ }
4541
5121
  // --- Register component health checks (after all systems initialized) ---
4542
5122
  // Queue store: can we read the JSONL file?
4543
5123
  health.register("queue", queueStoreCheck(() => queueProvider.getStore()), { critical: false });
@@ -4561,25 +5141,27 @@ async function start() {
4561
5141
  recovery.start();
4562
5142
  // Start alert evaluation loop (evaluates every 30s)
4563
5143
  alertManager.start();
4564
- // Wire notification channels — email (Resend) and phone (Twilio voice)
4565
- const resendKey = process.env.RESEND_API_KEY;
4566
- if (resendKey) {
4567
- alertDispatcher.add(new EmailChannel({
4568
- endpoint: "https://api.resend.com/emails",
4569
- apiKey: resendKey,
4570
- from: `${getInstanceName()} <${getAlertEmailFrom()}>`,
4571
- to: [resolveEnv("ALERT_EMAIL_TO") ?? ""].filter(Boolean),
4572
- }));
4573
- log.info("Alert channel: email (Resend)");
4574
- }
4575
- if (process.env.TWILIO_ACCOUNT_SID) {
4576
- alertDispatcher.add(new PhoneChannel());
4577
- log.info("Alert channel: phone (Twilio voice)");
4578
- }
4579
- alertManager.updateNotifications([
4580
- { channel: "email", minSeverity: "warning" },
4581
- { channel: "phone", minSeverity: "critical" },
4582
- ]);
5144
+ // Wire notification channels — tier >= byok only (requires BYOK API keys)
5145
+ if (tierGate.canAlert(tier)) {
5146
+ const resendKey = process.env.RESEND_API_KEY;
5147
+ if (resendKey) {
5148
+ alertDispatcher.add(new EmailChannel({
5149
+ endpoint: "https://api.resend.com/emails",
5150
+ apiKey: resendKey,
5151
+ from: `${getInstanceName()} <${getAlertEmailFrom()}>`,
5152
+ to: [resolveEnv("ALERT_EMAIL_TO") ?? ""].filter(Boolean),
5153
+ }));
5154
+ log.info("Alert channel: email (Resend)");
5155
+ }
5156
+ if (process.env.TWILIO_ACCOUNT_SID) {
5157
+ alertDispatcher.add(new PhoneChannel());
5158
+ log.info("Alert channel: phone (Twilio voice)");
5159
+ }
5160
+ alertManager.updateNotifications([
5161
+ { channel: "email", minSeverity: "warning" },
5162
+ { channel: "phone", minSeverity: "critical" },
5163
+ ]);
5164
+ }
4583
5165
  // Start credit monitoring (checks every 5 min, configurable via CORE_CREDIT_CHECK_INTERVAL_MS)
4584
5166
  startCreditMonitor(health, alertManager);
4585
5167
  // Avatar sidecar launches in background — slow (model loading) but non-blocking
@@ -4601,7 +5183,21 @@ async function start() {
4601
5183
  log.info(`${getInstanceName()} — Local Chat starting`);
4602
5184
  // Show LLM provider based on settings
4603
5185
  const provider = resolveProvider();
4604
- if (settings.airplaneMode) {
5186
+ if (settings.privateMode) {
5187
+ log.info("Mode: PRIVATE (network-isolated), LLM: Ollama only — cloud providers blocked");
5188
+ // Fail loudly at startup if Ollama isn't available in private mode
5189
+ try {
5190
+ const { assertOllamaAvailable } = await import("./llm/guard.js");
5191
+ await assertOllamaAvailable();
5192
+ log.info("Ollama: reachable ✓");
5193
+ }
5194
+ catch (err) {
5195
+ const msg = err instanceof Error ? err.message : String(err);
5196
+ log.error(msg);
5197
+ throw new Error("Cannot start in privateMode without Ollama. " + msg);
5198
+ }
5199
+ }
5200
+ else if (settings.airplaneMode) {
4605
5201
  log.info("Mode: Airplane (local only), LLM: Ollama");
4606
5202
  }
4607
5203
  else {
@@ -4767,10 +5363,46 @@ async function start() {
4767
5363
  return reply.trim();
4768
5364
  });
4769
5365
  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}`);
5366
+ await new Promise((resolve) => {
5367
+ const server = serve({ fetch: app.fetch, port: PORT }, () => {
5368
+ const addr = server.address();
5369
+ if (typeof addr === "object" && addr) {
5370
+ actualPort = addr.port;
5371
+ }
5372
+ log.info(`Listening on http://localhost:${actualPort}`);
5373
+ // Show LAN IP for phone access
5374
+ try {
5375
+ import("node:os").then(({ networkInterfaces }) => {
5376
+ const nets = networkInterfaces();
5377
+ for (const name of Object.keys(nets)) {
5378
+ for (const net of nets[name] ?? []) {
5379
+ if (net.family === "IPv4" && !net.internal) {
5380
+ log.info(`Nerve (phone): http://${net.address}:${actualPort}/nerve`);
5381
+ }
5382
+ }
5383
+ }
5384
+ });
5385
+ }
5386
+ catch { /* ok */ }
5387
+ // Announce on LAN if mesh.lanAnnounce is enabled AND tier >= byok
5388
+ if (tierGate.canMesh(tier) && getMeshConfig().lanAnnounce) {
5389
+ try {
5390
+ startMdns(actualPort);
5391
+ }
5392
+ catch (err) {
5393
+ log.warn("mDNS announcement failed — discovery disabled", {
5394
+ error: err instanceof Error ? err.message : String(err),
5395
+ });
5396
+ }
5397
+ }
5398
+ resolve();
5399
+ });
4772
5400
  });
4773
5401
  }
5402
+ /** Returns the port the server is actually listening on (resolves port 0). */
5403
+ export function getActualPort() {
5404
+ return actualPort;
5405
+ }
4774
5406
  export { start };
4775
5407
  const isDirectRun = process.argv[1]?.replace(/\\/g, "/").includes("server");
4776
5408
  if (isDirectRun) {
@@ -4779,6 +5411,87 @@ if (isDirectRun) {
4779
5411
  process.exit(1);
4780
5412
  });
4781
5413
  }
5414
+ function parseRoadmapYaml(raw) {
5415
+ const result = { phases: [], streams: [], milestones: [] };
5416
+ const lines = raw.split("\n");
5417
+ let section = null;
5418
+ let currentObj = null;
5419
+ function flush() {
5420
+ if (currentObj && section && section !== "layout") {
5421
+ result[section].push(currentObj);
5422
+ currentObj = null;
5423
+ }
5424
+ }
5425
+ for (const line of lines) {
5426
+ const trimmed = line.trimEnd();
5427
+ if (trimmed === "" || trimmed.startsWith("#"))
5428
+ continue;
5429
+ // Top-level section headers
5430
+ if (/^layout:\s*$/.test(trimmed)) {
5431
+ flush();
5432
+ section = "layout";
5433
+ result.layout = {};
5434
+ continue;
5435
+ }
5436
+ if (/^phases:\s*$/.test(trimmed)) {
5437
+ flush();
5438
+ section = "phases";
5439
+ continue;
5440
+ }
5441
+ if (/^streams:\s*$/.test(trimmed)) {
5442
+ flush();
5443
+ section = "streams";
5444
+ continue;
5445
+ }
5446
+ if (/^milestones:\s*$/.test(trimmed)) {
5447
+ flush();
5448
+ section = "milestones";
5449
+ continue;
5450
+ }
5451
+ if (!section)
5452
+ continue;
5453
+ // Layout is a flat object, not an array
5454
+ if (section === "layout") {
5455
+ const kvMatch = trimmed.match(/^\s+(\w+)\s*:\s*(.*)/);
5456
+ if (kvMatch && result.layout) {
5457
+ result.layout[kvMatch[1]] = kvMatch[2].replace(/^["']|["']$/g, "").trim();
5458
+ }
5459
+ continue;
5460
+ }
5461
+ // New list item: " - key: value"
5462
+ const newItemMatch = trimmed.match(/^\s+-\s+(\w+)\s*:\s*(.*)/);
5463
+ if (newItemMatch) {
5464
+ flush();
5465
+ currentObj = {};
5466
+ const key = newItemMatch[1];
5467
+ const val = newItemMatch[2].replace(/^["']|["']$/g, "").trim();
5468
+ if (val.startsWith("[")) {
5469
+ // Inline array: [a, b, c]
5470
+ currentObj[key] = val.slice(1, -1).split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
5471
+ }
5472
+ else {
5473
+ currentObj[key] = val;
5474
+ }
5475
+ continue;
5476
+ }
5477
+ // Continuation key: " key: value"
5478
+ if (currentObj) {
5479
+ const kvMatch = trimmed.match(/^\s+(\w+)\s*:\s*(.*)/);
5480
+ if (kvMatch) {
5481
+ const key = kvMatch[1];
5482
+ const val = kvMatch[2].replace(/^["']|["']$/g, "").trim();
5483
+ if (val.startsWith("[")) {
5484
+ currentObj[key] = val.slice(1, -1).split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
5485
+ }
5486
+ else {
5487
+ currentObj[key] = val;
5488
+ }
5489
+ }
5490
+ }
5491
+ }
5492
+ flush();
5493
+ return result;
5494
+ }
4782
5495
  // Graceful shutdown: agent pool first (drains queue, terminates agents, cleans resources),
4783
5496
  // then sidecars, timers, and monitors.
4784
5497
  async function gracefulShutdown(signal) {
@@ -4812,6 +5525,8 @@ async function gracefulShutdown(signal) {
4812
5525
  stopInsightsTimer();
4813
5526
  stopOpenLoopScanner();
4814
5527
  stopCreditMonitor();
5528
+ stopPushMonitor();
5529
+ stopMdns();
4815
5530
  stopAvatarSidecar();
4816
5531
  stopTtsSidecar();
4817
5532
  stopSttSidecar();