neoagent 3.0.1-beta.2 → 3.0.1-beta.21

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 (94) hide show
  1. package/.env.example +19 -0
  2. package/README.md +20 -3
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/getting-started.md +10 -8
  7. package/docs/index.md +1 -0
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +4 -1
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +316 -0
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1594 -224
  14. package/flutter_app/lib/main_controller.dart +263 -26
  15. package/flutter_app/lib/main_devices.dart +1 -1
  16. package/flutter_app/lib/main_install.dart +1147 -0
  17. package/flutter_app/lib/main_navigation.dart +12 -0
  18. package/flutter_app/lib/main_operations.dart +134 -9
  19. package/flutter_app/lib/main_settings.dart +148 -52
  20. package/flutter_app/lib/main_shared.dart +1 -0
  21. package/flutter_app/lib/src/backend_client.dart +86 -1
  22. package/landing/index.html +2 -2
  23. package/lib/manager.js +184 -66
  24. package/lib/schema_migrations.js +84 -0
  25. package/package.json +10 -4
  26. package/server/admin/access.js +12 -7
  27. package/server/admin/admin.css +78 -0
  28. package/server/admin/admin.js +511 -23
  29. package/server/admin/billing.js +415 -0
  30. package/server/admin/index.html +120 -13
  31. package/server/admin/users.js +15 -15
  32. package/server/db/database.js +13 -21
  33. package/server/db/sessions_db.js +8 -0
  34. package/server/http/middleware.js +4 -4
  35. package/server/http/routes.js +9 -0
  36. package/server/http/static.js +4 -2
  37. package/server/middleware/requireBilling.js +12 -0
  38. package/server/public/.last_build_id +1 -1
  39. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  40. package/server/public/flutter_bootstrap.js +1 -1
  41. package/server/public/main.dart.js +89347 -85449
  42. package/server/routes/account.js +53 -0
  43. package/server/routes/admin.js +515 -63
  44. package/server/routes/agents.js +203 -21
  45. package/server/routes/billing.js +127 -0
  46. package/server/routes/billing_webhook.js +43 -0
  47. package/server/routes/memory.js +1 -4
  48. package/server/routes/settings.js +1 -2
  49. package/server/routes/skills.js +1 -1
  50. package/server/routes/triggers.js +2 -2
  51. package/server/services/account/erasure.js +263 -0
  52. package/server/services/account/sessions.js +1 -9
  53. package/server/services/ai/loop/agent_engine_core.js +42 -0
  54. package/server/services/ai/loop/blank_recovery.js +36 -0
  55. package/server/services/ai/loop/completion_judge.js +42 -0
  56. package/server/services/ai/loop/conversation_loop.js +248 -34
  57. package/server/services/ai/loop/progress_monitor.js +6 -6
  58. package/server/services/ai/loopPolicy.js +37 -44
  59. package/server/services/ai/messagingFallback.js +25 -1
  60. package/server/services/ai/models.js +18 -0
  61. package/server/services/ai/preModelCompaction.js +25 -5
  62. package/server/services/ai/rate_limits.js +28 -4
  63. package/server/services/ai/systemPrompt.js +23 -5
  64. package/server/services/ai/taskAnalysis.js +2 -0
  65. package/server/services/ai/tools.js +231 -20
  66. package/server/services/android/controller.js +6 -2
  67. package/server/services/billing/billing_email.js +106 -0
  68. package/server/services/billing/config.js +17 -0
  69. package/server/services/billing/plans.js +121 -0
  70. package/server/services/billing/stripe_client.js +21 -0
  71. package/server/services/billing/subscriptions.js +462 -0
  72. package/server/services/billing/trial_guard.js +111 -0
  73. package/server/services/browser/contentExtractor.js +629 -0
  74. package/server/services/browser/controller.js +4 -8
  75. package/server/services/desktop/gateway.js +7 -4
  76. package/server/services/integrations/google/calendar.js +30 -2
  77. package/server/services/integrations/secrets.js +7 -4
  78. package/server/services/manager.js +11 -0
  79. package/server/services/messaging/automation.js +1 -1
  80. package/server/services/messaging/telnyx.js +12 -11
  81. package/server/services/messaging/whatsapp.js +1 -1
  82. package/server/services/recordings/manager.js +13 -7
  83. package/server/services/runtime/backends/local-vm.js +40 -22
  84. package/server/services/runtime/docker-vm-manager.js +157 -266
  85. package/server/services/runtime/guest_bootstrap.js +17 -5
  86. package/server/services/runtime/guest_image.js +182 -0
  87. package/server/services/runtime/manager.js +0 -1
  88. package/server/services/runtime/validation.js +3 -8
  89. package/server/services/tasks/runtime.js +103 -15
  90. package/server/services/tasks/schedule_utils.js +5 -5
  91. package/server/services/voice/runtimeManager.js +19 -10
  92. package/server/services/wearable/gateway.js +8 -5
  93. package/server/services/websocket.js +26 -8
  94. package/server/services/workspace/manager.js +37 -3
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { spawnSync } = require('child_process');
7
+ const { DATA_DIR } = require('../../../runtime/paths');
8
+ const { stageGuestPayload, normalizeRuntimeProfile } = require('./guest_bootstrap');
9
+
10
+ // Where build contexts are staged. One directory per profile.
11
+ const BUILD_ROOT = path.join(DATA_DIR, 'runtime-vms', 'guest-image');
12
+ // Slim official Node base — Playwright browsers + OS deps are added at build time
13
+ // via `playwright install --with-deps`, so the image is self-contained and the
14
+ // browser version always matches the pinned `playwright-chromium` dependency.
15
+ const BASE_IMAGE = String(process.env.NEOAGENT_GUEST_BASE_IMAGE || 'node:20-bookworm-slim').trim();
16
+ const IMAGE_REPO = 'neoagent-guest-agent';
17
+ const BUILD_TIMEOUT_MS = Number(process.env.NEOAGENT_GUEST_IMAGE_BUILD_TIMEOUT_MS || 20 * 60 * 1000);
18
+
19
+ // The build context is the staged guest payload (package.json + runtime/ + server/).
20
+ // Dependencies and browsers are installed once, at image build time — never per
21
+ // container start. Copy package.json first so the dependency layer stays cached
22
+ // across source-only changes.
23
+ function dockerfileFor(profile) {
24
+ if (normalizeRuntimeProfile(profile) === 'android') {
25
+ return [
26
+ `FROM ${BASE_IMAGE}`,
27
+ 'ENV NODE_ENV=production',
28
+ 'ENV NEOAGENT_GUEST_PROFILE=android',
29
+ 'WORKDIR /opt/neoagent',
30
+ 'COPY package.json ./',
31
+ 'RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force',
32
+ 'COPY runtime ./runtime',
33
+ 'COPY server ./server',
34
+ // World-writable runtime dir so the agent works whether the container runs
35
+ // as root (macOS) or as the host uid:gid (Linux, for shared file ownership).
36
+ 'RUN mkdir -p /opt/neoagent/.runtime && chmod -R 0777 /opt/neoagent/.runtime',
37
+ 'CMD ["node", "server/guest_agent.js"]',
38
+ '',
39
+ ].join('\n');
40
+ }
41
+ return [
42
+ `FROM ${BASE_IMAGE}`,
43
+ 'ENV NODE_ENV=production',
44
+ 'ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright',
45
+ 'ENV NEOAGENT_GUEST_PROFILE=browser_cli',
46
+ 'WORKDIR /opt/neoagent',
47
+ 'COPY package.json ./',
48
+ // Install deps without running package postinstall scripts, then fetch the
49
+ // matching Chromium and its OS dependencies explicitly. This mirrors the
50
+ // proven QEMU guest bootstrap and keeps the browser revision deterministic.
51
+ 'RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund \\',
52
+ ' && npx playwright install --with-deps chromium \\',
53
+ ' && npm cache clean --force',
54
+ 'COPY runtime ./runtime',
55
+ 'COPY server ./server',
56
+ // World-writable runtime dir + browser cache so the agent works whether the
57
+ // container runs as root (macOS) or as the host uid:gid (Linux, for shared
58
+ // file ownership). Chromium needs PLAYWRIGHT_BROWSERS_PATH readable by all.
59
+ 'RUN mkdir -p /opt/neoagent/.runtime && chmod -R 0777 /opt/neoagent/.runtime && chmod -R a+rX /ms-playwright',
60
+ 'CMD ["node", "server/guest_agent.js"]',
61
+ '',
62
+ ].join('\n');
63
+ }
64
+
65
+ // Stable content hash over every staged file plus the Dockerfile, so the image
66
+ // tag changes whenever the guest source, its dependencies, or the base image
67
+ // change. A new tag forces a rebuild; an unchanged tag reuses the cached image.
68
+ function hashBuildContext(contextDir, dockerfile) {
69
+ const hash = crypto.createHash('sha256');
70
+ hash.update(dockerfile);
71
+ const walk = (dir) => {
72
+ for (const name of fs.readdirSync(dir).sort()) {
73
+ const full = path.join(dir, name);
74
+ const stat = fs.statSync(full);
75
+ if (stat.isDirectory()) {
76
+ walk(full);
77
+ } else {
78
+ hash.update(path.relative(contextDir, full));
79
+ hash.update(fs.readFileSync(full));
80
+ }
81
+ }
82
+ };
83
+ walk(contextDir);
84
+ return hash.digest('hex').slice(0, 12);
85
+ }
86
+
87
+ function docker(args, opts = {}) {
88
+ return spawnSync('docker', args, {
89
+ encoding: 'utf8',
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ timeout: opts.timeout || 30000,
92
+ ...opts,
93
+ });
94
+ }
95
+
96
+ function dockerAvailable() {
97
+ const result = docker(['info'], { timeout: 5000 });
98
+ return !result.error && result.status === 0;
99
+ }
100
+
101
+ // Builds and caches the per-profile guest-agent Docker image. The image bakes in
102
+ // the guest agent, its dependencies, and (for browser_cli) the Chromium browser,
103
+ // so containers start the agent directly with no runtime installation step.
104
+ class GuestImageBuilder {
105
+ #buildPromise = null;
106
+ #cachedTag = null;
107
+ #cachedBuiltAt = 0;
108
+
109
+ constructor(options = {}) {
110
+ this.profile = normalizeRuntimeProfile(options.runtimeProfile || 'browser_cli');
111
+ this.contextDir = path.join(BUILD_ROOT, this.profile);
112
+ }
113
+
114
+ // Stage the build context on disk and return its content-addressed image tag.
115
+ prepareContext() {
116
+ const dockerfile = dockerfileFor(this.profile);
117
+ stageGuestPayload(this.contextDir, this.profile);
118
+ fs.writeFileSync(path.join(this.contextDir, 'Dockerfile'), dockerfile);
119
+ fs.writeFileSync(path.join(this.contextDir, '.dockerignore'), 'node_modules\n');
120
+ const tag = `${IMAGE_REPO}:${this.profile}-${hashBuildContext(this.contextDir, dockerfile)}`;
121
+ this.#cachedTag = tag;
122
+ return tag;
123
+ }
124
+
125
+ imageExists(tag) {
126
+ const result = docker(['image', 'inspect', tag], { timeout: 10000 });
127
+ return !result.error && result.status === 0;
128
+ }
129
+
130
+ // Returns { dockerAvailable, imageBuilt, image } without triggering a build.
131
+ // Cheap enough to call from readiness polling (only stages the context once).
132
+ getState() {
133
+ if (!dockerAvailable()) {
134
+ return { dockerAvailable: false, imageBuilt: false, image: this.#cachedTag };
135
+ }
136
+ let tag = this.#cachedTag;
137
+ try {
138
+ if (!tag) tag = this.prepareContext();
139
+ } catch (err) {
140
+ console.warn(`[GuestImage:${this.profile}] Failed to stage build context: ${err.message}`);
141
+ return { dockerAvailable: true, imageBuilt: false, image: null };
142
+ }
143
+ return { dockerAvailable: true, imageBuilt: this.imageExists(tag), image: tag };
144
+ }
145
+
146
+ // Ensure the image exists, building it if necessary. Concurrent callers share
147
+ // the same in-flight build. Returns the resolved image tag.
148
+ async ensure() {
149
+ const tag = this.prepareContext();
150
+ if (this.imageExists(tag)) {
151
+ this.#cachedBuiltAt = Date.now();
152
+ return tag;
153
+ }
154
+ if (this.#buildPromise) return this.#buildPromise;
155
+ this.#buildPromise = this.#build(tag).finally(() => { this.#buildPromise = null; });
156
+ return this.#buildPromise;
157
+ }
158
+
159
+ async #build(tag) {
160
+ console.log(`[GuestImage:${this.profile}] Building guest image ${tag} (one-time; downloads browser + deps)…`);
161
+ const started = Date.now();
162
+ const result = docker(['build', '-t', tag, this.contextDir], {
163
+ timeout: BUILD_TIMEOUT_MS,
164
+ stdio: ['ignore', 'inherit', 'inherit'],
165
+ });
166
+ if (result.error) {
167
+ throw new Error(`Guest image build failed to start: ${result.error.message}`);
168
+ }
169
+ if (result.status !== 0) {
170
+ throw new Error(`Guest image build for ${tag} exited with status ${result.status}`);
171
+ }
172
+ this.#cachedBuiltAt = Date.now();
173
+ console.log(`[GuestImage:${this.profile}] Built ${tag} in ${Math.round((Date.now() - started) / 1000)}s`);
174
+ return tag;
175
+ }
176
+ }
177
+
178
+ module.exports = {
179
+ GuestImageBuilder,
180
+ dockerAvailable,
181
+ BASE_IMAGE,
182
+ };
@@ -19,7 +19,6 @@ class RuntimeManager {
19
19
 
20
20
  const browserVmManager = options.browserVmManager || new DockerVMManager({
21
21
  runtimeProfile: 'browser_cli',
22
- image: 'mcr.microsoft.com/playwright:v1.44.0-focal',
23
22
  memoryMb: DEFAULT_VM_MEMORY_MB,
24
23
  cpus: DEFAULT_VM_CPUS,
25
24
  });
@@ -11,14 +11,9 @@ function getRuntimeValidation(runtimeManager) {
11
11
 
12
12
  if (policy.profile === 'prod' || nodeEnvIsProd) {
13
13
  if (!browserVmReadiness) {
14
- issues.push('prod profile requires a working local VM runtime for browser/CLI.');
15
- } else if (!browserVmReadiness.ready) {
16
- if (!browserVmReadiness.qemuAvailable) {
17
- issues.push(`prod profile requires QEMU (${browserVmReadiness.qemuBinary}) to be installed for browser/CLI.`);
18
- }
19
- if (!browserVmReadiness.baseImageExists && !browserVmReadiness.downloadConfigured) {
20
- issues.push('prod profile requires a VM base image or a downloadable base image URL for browser/CLI.');
21
- }
14
+ issues.push('prod profile requires a working container runtime for browser/CLI.');
15
+ } else if (!browserVmReadiness.dockerAvailable) {
16
+ issues.push('prod profile requires Docker to be installed and running for the browser/CLI runtime.');
22
17
  }
23
18
  }
24
19
 
@@ -14,6 +14,7 @@ const { TriggerRegistry } = require('./trigger_registry');
14
14
  const scheduleAdapter = require('./adapters/schedule');
15
15
  const { normalizeJsonObject } = require('./utils');
16
16
  const { normalizeOutgoingMessageForPlatform } = require('../messaging/formatting_guides');
17
+ const { isTransientError } = require('../ai/providerRetry');
17
18
 
18
19
  const MAX_AUTONOMOUS_RETRIES = 1;
19
20
  const MAX_RECURRING_TASK_START_DELAY_MS = 90 * 1000;
@@ -461,6 +462,8 @@ class TaskRuntime {
461
462
  const deliveryState = {
462
463
  messagingSent: false,
463
464
  noResponse: false,
465
+ proactiveMessageStaged: false,
466
+ stagedProactiveMessage: null,
464
467
  lastSentMessage: '',
465
468
  sentMessages: [],
466
469
  };
@@ -483,11 +486,12 @@ class TaskRuntime {
483
486
  normalizedConfig = this._ensureDefaultNotifyTarget(userId, agentId, taskConfig, taskId);
484
487
  const triggerSummary = this._summarizeTrigger(task.trigger_type, triggerConfig);
485
488
  let notifyHint = '';
489
+ const manualRun = executionMeta.manual === true;
486
490
 
487
491
  if (normalizedConfig.callTo) {
488
492
  notifyHint = `\n\nThis task is configured to notify the user by phone. Use the make_call tool to call "${normalizedConfig.callTo}" with an appropriate greeting based on your findings. The configured greeting hint is: "${normalizedConfig.callGreeting || 'Hello, this is your task reminder.'}"`;
489
493
  } else if (normalizedConfig.notifyPlatform && normalizedConfig.notifyTo) {
490
- notifyHint = `\n\nIf your task result is worth notifying the user about, send it proactively via send_message to platform="${normalizedConfig.notifyPlatform}" to="${normalizedConfig.notifyTo}" and set purpose="final_result" for a concrete useful outcome or purpose="blocker" for a real issue the user should know about. If nothing important or actionable changed, call send_message with purpose="no_response" and content="[NO RESPONSE]".`;
494
+ notifyHint = `\n\nIf your task result is worth notifying the user about, send it proactively via send_message to platform="${normalizedConfig.notifyPlatform}" to="${normalizedConfig.notifyTo}" and set purpose="final_result" for a concrete useful outcome or purpose="blocker" for a real issue the user should know about. If nothing important or actionable changed, call send_message with purpose="no_response" and content="[NO RESPONSE]" exactly; never leave content blank for no_response. When a tool result already gives you summary fields or flags that answer the task, decide from that evidence instead of re-running nearby variants of the same lookup.${manualRun ? '' : ' For this automatic scheduled run, plain assistant text is internal only and is NOT delivered. You MUST end the run with exactly one explicit send_message decision (purpose="final_result", "blocker", or "no_response") — if you produce a real result, deliver it with send_message or it is lost.'}`;
491
495
  }
492
496
 
493
497
  const triggerPayloadText = executionMeta.triggerPayload
@@ -519,13 +523,14 @@ class TaskRuntime {
519
523
  taskId,
520
524
  deliveryState,
521
525
  allowMultipleProactiveMessages: normalizedConfig.allowMultipleMessages === true || normalizedConfig.allow_multiple_messages === true,
526
+ stageProactiveMessages: true,
522
527
  skipTaskAnalysis: true,
523
528
  skipDeliverableWorkflow: true,
524
529
  skipGlobalRecall: true,
525
530
  skipConversationHistory: true,
526
531
  skipConversationMaintenance: true,
527
532
  skipRunContextPersistence: true,
528
- skipVerifier: true,
533
+ skipVerifier: false,
529
534
  stream: false,
530
535
  context: executionMeta.triggerPayload || {},
531
536
  };
@@ -541,6 +546,7 @@ class TaskRuntime {
541
546
  taskConfig: normalizedConfig,
542
547
  result,
543
548
  deliveryState,
549
+ allowPlainResultFallback: manualRun,
544
550
  });
545
551
  if (fallbackDelivery && result && typeof result === 'object') {
546
552
  result.taskDelivery = fallbackDelivery;
@@ -562,10 +568,19 @@ class TaskRuntime {
562
568
  this.io.to(`user:${userId}`).emit('tasks:task_complete', { taskId, result });
563
569
  return result;
564
570
  } catch (err) {
565
- if (completedRunId) {
571
+ const transientExecutionError = isTransientError(err);
572
+ if (completedRunId && !transientExecutionError) {
566
573
  this.taskRepository.markAgentRunFailed(completedRunId, userId, err.message);
567
574
  }
568
575
  if (err?.code === 'TASK_DELIVERY_FAILED') throw err;
576
+ if (transientExecutionError) {
577
+ this.io.to(`user:${userId}`).emit('tasks:task_skipped', {
578
+ taskId,
579
+ reason: 'transient_rate_limit',
580
+ timestamp: new Date().toISOString(),
581
+ });
582
+ return { skipped: true, reason: 'transient_rate_limit', runId: completedRunId, error: err.message };
583
+ }
569
584
  if (attempt >= MAX_AUTONOMOUS_RETRIES) throw err;
570
585
  attempt += 1;
571
586
  completedRunId = null;
@@ -584,20 +599,28 @@ class TaskRuntime {
584
599
  } catch (err) {
585
600
  console.error(`[Tasks] Task ${taskId} error:`, err.message);
586
601
  if (err?.code !== 'TASK_DELIVERY_FAILED') {
587
- await this._deliverTaskResultIfNeeded({
588
- userId,
589
- agentId,
590
- taskId,
591
- taskConfig: normalizedConfig,
592
- result: {
593
- content: `Background task "${taskName}" could not complete after retrying. Check the task run logs for details.`,
594
- },
595
- deliveryState,
596
- });
602
+ const failureMessage = this._buildTaskFailureMessage(taskName, err);
603
+ // A null message means the failure is transient infrastructure (rate/quota
604
+ // limit) that is not user-actionable and would spam every run during the
605
+ // limit window — it is logged above, but not surfaced to the user.
606
+ if (failureMessage) {
607
+ await this._deliverTaskResultIfNeeded({
608
+ userId,
609
+ agentId,
610
+ taskId,
611
+ taskConfig: normalizedConfig,
612
+ result: {
613
+ content: failureMessage,
614
+ },
615
+ deliveryState,
616
+ allowPlainResultFallback: true,
617
+ });
618
+ }
597
619
  }
598
620
  this.io.to(`user:${userId}`).emit('tasks:task_skipped', {
599
621
  taskId,
600
622
  reason: 'execution_failed',
623
+ error: err.message,
601
624
  timestamp: new Date().toISOString(),
602
625
  });
603
626
  return { skipped: false, error: err.message, runId: completedRunId };
@@ -784,6 +807,29 @@ class TaskRuntime {
784
807
  return normalized;
785
808
  }
786
809
 
810
+ // Build the user-facing notice for a task that errored out after retries.
811
+ // Returns null when the failure should NOT be surfaced (transient infra limits).
812
+ _buildTaskFailureMessage(taskName, err) {
813
+ const raw = String(err?.message || '').trim();
814
+ const lower = raw.toLowerCase();
815
+ // Rate/quota limits are transient, self-healing, and not user-actionable. Every
816
+ // scheduled run during the window would hit the same error, so a per-run notice
817
+ // would just be spam. Log only (done by the caller), no user message.
818
+ if (
819
+ lower.includes('rate limit')
820
+ || lower.includes('rate_limit')
821
+ || lower.includes('quota')
822
+ || lower.includes('tokens in the last')
823
+ || /\b429\b/.test(lower)
824
+ ) {
825
+ return null;
826
+ }
827
+ // Genuine failure: tell the user the actual reason instead of "check the logs",
828
+ // which they cannot do. Collapse whitespace and cap length to keep it readable.
829
+ const reason = raw ? raw.replace(/\s+/g, ' ').slice(0, 200) : 'an unknown error';
830
+ return `Background task "${taskName}" could not complete: ${reason}`;
831
+ }
832
+
787
833
  async _deliverTaskResultIfNeeded({
788
834
  userId,
789
835
  agentId,
@@ -791,10 +837,42 @@ class TaskRuntime {
791
837
  taskConfig,
792
838
  result,
793
839
  deliveryState,
840
+ allowPlainResultFallback = true,
794
841
  }) {
795
842
  if (deliveryState?.messagingSent || deliveryState?.noResponse || taskConfig.callTo) return null;
796
843
  const targets = this._buildNotifyTargets(userId, agentId, taskConfig);
797
844
  if (!targets.length) return null;
845
+ const resultText = stringifyTaskResult(result).trim();
846
+ const resultLooksLikeError = Boolean(result?.error);
847
+ const stagedMessage = normalizeOutgoingMessageForPlatform(
848
+ deliveryState?.stagedProactiveMessage?.platform,
849
+ deliveryState?.stagedProactiveMessage?.content || '',
850
+ { stripNoResponseMarker: false },
851
+ );
852
+ const explicitStagedDelivery = deliveryState?.proactiveMessageStaged === true && Boolean(stagedMessage);
853
+ // A forced terminal wrap-up (read-only/blocked hard-stop) is the model's final
854
+ // answer produced WITHOUT the ability to call send_message itself. Gating it
855
+ // would silently drop a stuck scheduled task's result, so deliver it even on an
856
+ // automatic run. Ordinary mid-run plain text (model had send_message available
857
+ // and chose not to use it) is still gated below.
858
+ const forcedTerminal = deliveryState?.terminalWrapup === true && Boolean(resultText);
859
+ if (!allowPlainResultFallback && !resultLooksLikeError && !forcedTerminal && !explicitStagedDelivery) {
860
+ // Automatic run produced substantive text but never made an explicit
861
+ // send_message decision (a deliberate no_response would have short-circuited
862
+ // above). We suppress to avoid recurring-check spam, but surface it so a
863
+ // genuinely dropped notification is visible rather than silently lost.
864
+ if (resultText) {
865
+ console.warn(
866
+ `[Tasks] Task ${taskId} produced an undelivered result on an automatic run `
867
+ + `(no explicit send_message decision): ${resultText.slice(0, 140)}`
868
+ );
869
+ }
870
+ return {
871
+ sent: false,
872
+ skipped: true,
873
+ reason: 'explicit_delivery_required',
874
+ };
875
+ }
798
876
 
799
877
  const manager = this.app?.locals?.messagingManager || this.agentEngine?.messagingManager || null;
800
878
  if (!manager) {
@@ -805,10 +883,17 @@ class TaskRuntime {
805
883
  }
806
884
 
807
885
  let lastError = null;
808
- for (const target of targets) {
886
+ const resolvedTargets = explicitStagedDelivery
887
+ ? [{
888
+ platform: deliveryState.stagedProactiveMessage.platform,
889
+ to: deliveryState.stagedProactiveMessage.to,
890
+ mediaPath: deliveryState.stagedProactiveMessage.mediaPath || null,
891
+ }]
892
+ : targets;
893
+ for (const target of resolvedTargets) {
809
894
  const message = normalizeOutgoingMessageForPlatform(
810
895
  target.platform,
811
- stringifyTaskResult(result),
896
+ resultText || stagedMessage,
812
897
  { stripNoResponseMarker: false },
813
898
  );
814
899
  if (!message || message.toUpperCase() === '[NO RESPONSE]') return null;
@@ -824,10 +909,13 @@ class TaskRuntime {
824
909
  try {
825
910
  const sendResult = await manager.sendMessage(userId, target.platform, target.to, message, {
826
911
  agentId,
912
+ mediaPath: target.mediaPath || null,
827
913
  runId: result?.runId || null,
828
914
  persistConversation: true,
829
915
  });
830
916
  deliveryState.messagingSent = true;
917
+ deliveryState.proactiveMessageStaged = false;
918
+ deliveryState.stagedProactiveMessage = null;
831
919
  deliveryState.lastSentMessage = message;
832
920
  if (!Array.isArray(deliveryState.sentMessages)) {
833
921
  deliveryState.sentMessages = [];
@@ -141,11 +141,11 @@ function parseCronExpression(expression) {
141
141
  }
142
142
 
143
143
  function matchesCron(date, schedule) {
144
- const minute = date.getUTCMinutes();
145
- const hour = date.getUTCHours();
146
- const dayOfMonth = date.getUTCDate();
147
- const month = date.getUTCMonth() + 1;
148
- const dayOfWeek = date.getUTCDay();
144
+ const minute = date.getMinutes();
145
+ const hour = date.getHours();
146
+ const dayOfMonth = date.getDate();
147
+ const month = date.getMonth() + 1;
148
+ const dayOfWeek = date.getDay();
149
149
 
150
150
  if (!schedule.minute.values.has(minute)) return false;
151
151
  if (!schedule.hour.values.has(hour)) return false;
@@ -52,6 +52,9 @@ class VoiceRuntimeManager {
52
52
  agentId,
53
53
  );
54
54
  const resolvedSessionId = String(sessionId || randomUUID()).trim();
55
+ if (this.sessions.has(resolvedSessionId)) {
56
+ throw new Error('Voice session ID collision: a session with this ID already exists.');
57
+ }
55
58
  const adapter = this.#createAdapter(voiceSettings.liveProvider);
56
59
  await adapter.open();
57
60
 
@@ -153,9 +156,12 @@ class VoiceRuntimeManager {
153
156
  });
154
157
  }
155
158
 
156
- async closeSession(sessionId, reason = 'closed') {
159
+ async closeSession(sessionId, reason = 'closed', userId = null) {
157
160
  const session = this.getSession(sessionId);
158
161
  if (!session) return;
162
+ if (userId != null && session.userId != null && String(session.userId) !== String(userId)) {
163
+ throw new Error('Voice session access denied.');
164
+ }
159
165
  if (reason === 'socket_disconnected') {
160
166
  await this.abortActiveRun(session.id, 'voice_disconnect');
161
167
  }
@@ -164,8 +170,8 @@ class VoiceRuntimeManager {
164
170
  await session.close(reason);
165
171
  }
166
172
 
167
- async beginInput(sessionId, options = {}) {
168
- const session = this.#requireSession(sessionId);
173
+ async beginInput(sessionId, options = {}, userId = null) {
174
+ const session = this.#requireSession(sessionId, userId);
169
175
  await session.interruptOutput();
170
176
  await this.abortActiveRun(session.id, 'voice_interrupt');
171
177
  session.resetTurnState();
@@ -176,13 +182,13 @@ class VoiceRuntimeManager {
176
182
  await session.setState('listening');
177
183
  }
178
184
 
179
- async appendInputAudio(sessionId, audioBytes, options = {}) {
180
- const session = this.#requireSession(sessionId);
185
+ async appendInputAudio(sessionId, audioBytes, options = {}, userId = null) {
186
+ const session = this.#requireSession(sessionId, userId);
181
187
  return session.adapter.appendAudioChunk(session, audioBytes, options);
182
188
  }
183
189
 
184
- async commitInput(sessionId, options = {}) {
185
- const session = this.#requireSession(sessionId);
190
+ async commitInput(sessionId, options = {}, userId = null) {
191
+ const session = this.#requireSession(sessionId, userId);
186
192
  if (session.inputBytes === 0) {
187
193
  return { transcript: '' };
188
194
  }
@@ -207,8 +213,8 @@ class VoiceRuntimeManager {
207
213
  };
208
214
  }
209
215
 
210
- async interruptSession(sessionId) {
211
- const session = this.#requireSession(sessionId);
216
+ async interruptSession(sessionId, userId = null) {
217
+ const session = this.#requireSession(sessionId, userId);
212
218
  await this.abortActiveRun(session.id, 'voice_interrupt');
213
219
  await session.interruptOutput();
214
220
  session.resetTurnState();
@@ -378,11 +384,14 @@ class VoiceRuntimeManager {
378
384
  return new OpenAiLiveRelayAdapter();
379
385
  }
380
386
 
381
- #requireSession(sessionId) {
387
+ #requireSession(sessionId, userId = null) {
382
388
  const session = this.getSession(sessionId);
383
389
  if (!session) {
384
390
  throw new Error('Voice session was not found.');
385
391
  }
392
+ if (userId != null && session.userId != null && String(session.userId) !== String(userId)) {
393
+ throw new Error('Voice session access denied.');
394
+ }
386
395
  return session;
387
396
  }
388
397
 
@@ -27,11 +27,14 @@ function rejectUpgrade(socket, statusCode, message) {
27
27
  }
28
28
 
29
29
  function remoteAddressFromRequest(req) {
30
- const forwarded = req.headers?.['x-forwarded-for'];
31
- if (typeof forwarded === 'string' && forwarded.trim()) {
32
- return forwarded.split(',')[0].trim();
30
+ const directPeer = req.socket?.remoteAddress || 'unknown';
31
+ if (process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1') {
32
+ const forwarded = req.headers?.['x-forwarded-for'];
33
+ if (typeof forwarded === 'string' && forwarded.trim()) {
34
+ return forwarded.split(',')[0].trim();
35
+ }
33
36
  }
34
- return req.socket?.remoteAddress || 'unknown';
37
+ return directPeer;
35
38
  }
36
39
 
37
40
  function createUpgradeLimiter() {
@@ -142,7 +145,7 @@ function createWearableVoiceSink(ws, voiceRuntimeManager) {
142
145
  }
143
146
 
144
147
  function bindWearableGateway(httpServer, app, sessionMiddleware) {
145
- const wss = new WebSocketServer({ noServer: true });
148
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 });
146
149
  const allowUpgradeAttempt = createUpgradeLimiter();
147
150
 
148
151
  httpServer.on('upgrade', (req, socket, head) => {
@@ -15,6 +15,11 @@ const MAX_QUERY_CHARS = 2000;
15
15
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 10 * 1000;
16
16
  const DEFAULT_RATE_LIMIT_MAX = 30;
17
17
  const RATE_LIMIT_OBSERVER_ENTRY_TTL_MS = 10 * 60 * 1000;
18
+ // Shared rate-limit state keyed by userId so that multiple sockets from the
19
+ // same authenticated user share a single budget rather than getting N × budget.
20
+ const userRateLimitStates = new Map();
21
+ const userSocketCounts = new Map();
22
+
18
23
  const EVENT_RATE_LIMITS = Object.freeze({
19
24
  'agent:run': { windowMs: 15 * 1000, max: 4 },
20
25
  'agent:abort': { windowMs: 10 * 1000, max: 20 },
@@ -74,8 +79,7 @@ function resolveAgentFromPayload(userId, value) {
74
79
  return resolveAgentId(userId, data?.agentId || data?.agent_id || null);
75
80
  }
76
81
 
77
- function createSocketRateLimiter() {
78
- const state = new Map();
82
+ function createSocketRateLimiter(state = new Map()) {
79
83
  return (eventName) => {
80
84
  const config = EVENT_RATE_LIMITS[eventName] || {
81
85
  windowMs: DEFAULT_RATE_LIMIT_WINDOW_MS,
@@ -184,7 +188,6 @@ function setupWebSocket(io, services) {
184
188
  services.app.locals.getWebsocketRateLimitSnapshot = () => rateLimitObserver.snapshot();
185
189
  }
186
190
  io.on('connection', (socket) => {
187
- const allowEvent = createSocketRateLimiter();
188
191
  const session = socket.request.session;
189
192
  if (!session?.userId) {
190
193
  console.warn(`[WS] Rejecting unauthenticated socket ${socket.id}`);
@@ -195,6 +198,13 @@ function setupWebSocket(io, services) {
195
198
  const userId = session.userId;
196
199
  socket.join(`user:${userId}`);
197
200
 
201
+ // Share rate-limit budget across all sockets from the same authenticated user.
202
+ userSocketCounts.set(userId, (userSocketCounts.get(userId) || 0) + 1);
203
+ if (!userRateLimitStates.has(userId)) {
204
+ userRateLimitStates.set(userId, new Map());
205
+ }
206
+ const allowEvent = createSocketRateLimiter(userRateLimitStates.get(userId));
207
+
198
208
  console.log(`[WS] User ${userId} connected (${socket.id})`);
199
209
 
200
210
  // ── Agent Events ──
@@ -544,7 +554,7 @@ function setupWebSocket(io, services) {
544
554
  await voiceRuntimeManager.beginInput(sessionId, {
545
555
  mimeType: toOptionalString(data?.mimeType, 128),
546
556
  turnId: toOptionalString(data?.turnId, 128),
547
- });
557
+ }, userId);
548
558
  } catch (err) {
549
559
  console.error(`[WS] voice:input_start failed for user ${userId}:`, err);
550
560
  socket.emit('voice:error', {
@@ -600,7 +610,7 @@ function setupWebSocket(io, services) {
600
610
  mimeType: toOptionalString(data?.mimeType, 128),
601
611
  turnId,
602
612
  sequence,
603
- });
613
+ }, userId);
604
614
  socket.emit('voice:chunk_ack', {
605
615
  sessionId,
606
616
  turnId,
@@ -672,7 +682,7 @@ function setupWebSocket(io, services) {
672
682
  finalSequence: toBoundedInt(data?.finalSequence, -1, -1, 1_000_000),
673
683
  promptHint: toOptionalString(data?.promptHint, 2000),
674
684
  metadata,
675
- });
685
+ }, userId);
676
686
  } catch (err) {
677
687
  console.error(`[WS] voice:input_commit failed for user ${userId}:`, err);
678
688
  socket.emit('voice:error', {
@@ -696,7 +706,7 @@ function setupWebSocket(io, services) {
696
706
  if (!sessionId) {
697
707
  return socket.emit('voice:error', { error: 'sessionId is required' });
698
708
  }
699
- await voiceRuntimeManager.interruptSession(sessionId);
709
+ await voiceRuntimeManager.interruptSession(sessionId, userId);
700
710
  } catch (err) {
701
711
  console.error(`[WS] voice:interrupt failed for user ${userId}:`, err);
702
712
  socket.emit('voice:error', {
@@ -720,7 +730,7 @@ function setupWebSocket(io, services) {
720
730
  if (!sessionId) {
721
731
  return socket.emit('voice:error', { error: 'sessionId is required' });
722
732
  }
723
- await voiceRuntimeManager.closeSession(sessionId, 'client_closed');
733
+ await voiceRuntimeManager.closeSession(sessionId, 'client_closed', userId);
724
734
  socket.data.voiceSessionIds?.delete(sessionId);
725
735
  } catch (err) {
726
736
  console.error(`[WS] voice:session_close failed for user ${userId}:`, err);
@@ -911,6 +921,14 @@ function setupWebSocket(io, services) {
911
921
  // ── Disconnect ──
912
922
 
913
923
  socket.on('disconnect', () => {
924
+ const remaining = (userSocketCounts.get(userId) || 1) - 1;
925
+ if (remaining <= 0) {
926
+ userSocketCounts.delete(userId);
927
+ userRateLimitStates.delete(userId);
928
+ } else {
929
+ userSocketCounts.set(userId, remaining);
930
+ }
931
+
914
932
  const streamHub = services.streamHub || services.app?.locals?.streamHub;
915
933
  if (streamHub && typeof streamHub.unsubscribeAll === 'function') {
916
934
  void streamHub.unsubscribeAll(socket.id).catch((err) => {