@evomap/evolver 1.84.0 → 1.84.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/assets/gep/genes.seed.json +17 -15
  2. package/index.js +52 -16
  3. package/package.json +4 -3
  4. package/src/adapters/claudeCode.js +44 -31
  5. package/src/adapters/codex.js +70 -26
  6. package/src/adapters/cursor.js +3 -1
  7. package/src/adapters/hookAdapter.js +142 -2
  8. package/src/adapters/kiro.js +6 -14
  9. package/src/adapters/opencode.js +6 -14
  10. package/src/adapters/scripts/_runtimePaths.js +114 -0
  11. package/src/adapters/scripts/evolver-session-end.js +37 -61
  12. package/src/adapters/scripts/evolver-session-start.js +1 -31
  13. package/src/atp/hubClient.js +3 -1
  14. package/src/config.js +20 -1
  15. package/src/evolve/guards.js +1 -1
  16. package/src/evolve/pipeline/collect.js +1 -1
  17. package/src/evolve/pipeline/dispatch.js +1 -1
  18. package/src/evolve/pipeline/enrich.js +1 -1
  19. package/src/evolve/pipeline/hub.js +1 -1
  20. package/src/evolve/pipeline/select.js +1 -1
  21. package/src/evolve/pipeline/signals.js +1 -1
  22. package/src/evolve/utils.js +1 -1
  23. package/src/evolve.js +1 -1
  24. package/src/forceUpdate.js +5 -21
  25. package/src/gep/a2aProtocol.js +1 -1
  26. package/src/gep/assetStore.js +27 -6
  27. package/src/gep/candidateEval.js +1 -1
  28. package/src/gep/candidates.js +1 -1
  29. package/src/gep/contentHash.js +1 -1
  30. package/src/gep/crypto.js +1 -1
  31. package/src/gep/curriculum.js +1 -1
  32. package/src/gep/deviceId.js +1 -1
  33. package/src/gep/directoryClient.js +4 -3
  34. package/src/gep/envFingerprint.js +1 -1
  35. package/src/gep/epigenetics.js +1 -1
  36. package/src/gep/explore.js +1 -1
  37. package/src/gep/gitOps.js +0 -5
  38. package/src/gep/hash.js +1 -1
  39. package/src/gep/hubFetch.js +1 -0
  40. package/src/gep/hubReview.js +1 -1
  41. package/src/gep/hubSearch.js +1 -1
  42. package/src/gep/hubVerify.js +1 -1
  43. package/src/gep/learningSignals.js +1 -1
  44. package/src/gep/mailboxTransport.js +8 -5
  45. package/src/gep/memoryGraph.js +1 -1
  46. package/src/gep/memoryGraphAdapter.js +1 -1
  47. package/src/gep/mutation.js +1 -1
  48. package/src/gep/narrativeMemory.js +1 -1
  49. package/src/gep/openPRRegistry.js +1 -1
  50. package/src/gep/personality.js +1 -1
  51. package/src/gep/policyCheck.js +1 -1
  52. package/src/gep/prompt.js +1 -1
  53. package/src/gep/recallVerifier.js +1 -1
  54. package/src/gep/reflection.js +1 -1
  55. package/src/gep/sanitize.js +2 -1
  56. package/src/gep/schemas/gene.js +70 -1
  57. package/src/gep/schemas/protocol.js +9 -1
  58. package/src/gep/selector.js +1 -1
  59. package/src/gep/selfPR.js +62 -34
  60. package/src/gep/skillDistiller.js +1 -1
  61. package/src/gep/skillPublisher.js +3 -2
  62. package/src/gep/solidify.js +1 -1
  63. package/src/gep/strategy.js +1 -1
  64. package/src/gep/taskReceiver.js +6 -5
  65. package/src/gep/validator/index.js +10 -6
  66. package/src/gep/validator/reporter.js +2 -1
  67. package/src/gep/validator/stakeBootstrap.js +2 -1
  68. package/src/ops/health_check.js +1 -11
  69. package/src/ops/lifecycle.js +1 -3
  70. package/src/proxy/index.js +69 -0
  71. package/src/proxy/lifecycle/manager.js +3 -2
  72. package/src/proxy/router/cache_passthrough.js +26 -0
  73. package/src/proxy/router/features.js +84 -0
  74. package/src/proxy/router/messages_route.js +242 -0
  75. package/src/proxy/router/model_router.js +113 -0
  76. package/src/proxy/server/http.js +108 -6
  77. package/src/proxy/server/routes.js +12 -2
  78. package/src/proxy/server/settings.js +43 -10
  79. package/src/proxy/sync/inbound.js +3 -2
  80. package/src/proxy/sync/outbound.js +2 -1
  81. package/src/webui/observer/interactions.js +22 -16
  82. package/scripts/check_wrapper_compat.js +0 -113
  83. package/src/gep/.integrity +0 -0
  84. package/src/gep/integrityCheck.js +0 -1
  85. package/src/gep/shield.js +0 -1
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ // /v1/messages handler for Phase C. Three pipeline stages wrap _proxyAnthropic:
4
+ // 1. extract stateless features (router/features.js)
5
+ // 2. pickForTurn → tier → concrete model (router/model_router.js + DEFAULT_TIER_MODELS)
6
+ // 3. rewriteModel preserving cache_control breakpoints (router/cache_passthrough.js)
7
+ //
8
+ // Each stage has its own fallback so a single bad input never breaks the
9
+ // passthrough: classifier throw → forward unmodified; rewriter throw →
10
+ // forward unmodified; upstream 5xx on a router-rewritten request → one
11
+ // retry with the client's original model (a one-hub/prism-style gateway
12
+ // may return 503 "no channel" for a tier-target model the upstream isn't
13
+ // configured for; falling back to the original model is more useful than
14
+ // a hard 503). All other non-2xx is relayed verbatim — we don't fabricate
15
+ // SSE error frames. Telemetry-style log lines record which fallback fired
16
+ // so the realized-vs-projected delta is measurable post-merge.
17
+
18
+ const { pickForTurn } = require('./model_router');
19
+ const { rewriteModel } = require('./cache_passthrough');
20
+ const { extractFeatures } = require('./features');
21
+
22
+ const DEFAULT_TIER_MODELS = Object.freeze({
23
+ cheap: 'claude-haiku-4-5',
24
+ mid: 'claude-sonnet-4-6',
25
+ expensive: 'claude-opus-4-7',
26
+ });
27
+
28
+ function resolveTierModels() {
29
+ return {
30
+ cheap: process.env.EVOMAP_MODEL_CHEAP || DEFAULT_TIER_MODELS.cheap,
31
+ mid: process.env.EVOMAP_MODEL_MID || DEFAULT_TIER_MODELS.mid,
32
+ expensive: process.env.EVOMAP_MODEL_EXPENSIVE || DEFAULT_TIER_MODELS.expensive,
33
+ };
34
+ }
35
+
36
+ function buildMessagesHandler({ anthropicProxy, logger, routerEnabled } = {}) {
37
+ if (typeof anthropicProxy !== 'function') {
38
+ throw new Error('buildMessagesHandler requires anthropicProxy(path, body, opts)');
39
+ }
40
+ const log = logger || console;
41
+ // Phase C slice 6: flag is read at handler construction (proxy start), not
42
+ // per-request — flipping the env var requires a proxy restart, fine for an
43
+ // MVP feature flag. Explicit boolean override wins so tests stay hermetic.
44
+ const enabled = typeof routerEnabled === 'boolean'
45
+ ? routerEnabled
46
+ : process.env.EVOMAP_ROUTER_ENABLED === '1';
47
+
48
+ return async ({ body, headers }) => {
49
+ const inboundHeaders = headers || {};
50
+ // x-api-key is satisfied by either the inbound header OR a proxy-side
51
+ // ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env (token mediation, see
52
+ // _proxyAnthropic). The proxy server itself has already auth-checked
53
+ // `Authorization: Bearer <proxy_token>` before reaching this handler.
54
+ const hasInboundKey = !!inboundHeaders['x-api-key'];
55
+ const hasProxyEnvCreds = !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
56
+ if (!hasInboundKey && !hasProxyEnvCreds) {
57
+ throw Object.assign(new Error('x-api-key required'), { statusCode: 401 });
58
+ }
59
+
60
+ const originalModel = body && typeof body.model === 'string' ? body.model : null;
61
+ let chosenModel = originalModel;
62
+ let decisionTier = null;
63
+ let decisionReason = null;
64
+ let fallback = null;
65
+
66
+ if (enabled) {
67
+ try {
68
+ const features = extractFeatures(body);
69
+ const decision = pickForTurn({
70
+ features,
71
+ router_state: { history: [], pinned: null },
72
+ config: { default_tier: 'mid', disable: false, hard_pin_after_plan: false },
73
+ });
74
+ decisionTier = decision.tier;
75
+ decisionReason = decision.reason;
76
+ const tierModel = resolveTierModels()[decision.tier];
77
+ if (tierModel) chosenModel = tierModel;
78
+ } catch (err) {
79
+ fallback = 'classifier_error';
80
+ log.warn?.(JSON.stringify({
81
+ event: 'router_fallback',
82
+ reason: 'classifier_error',
83
+ original_model: originalModel,
84
+ would_have_been: null,
85
+ error: err.message,
86
+ }));
87
+ }
88
+
89
+ }
90
+
91
+ let outboundBody = body;
92
+ if (enabled && chosenModel && chosenModel !== originalModel) {
93
+ try {
94
+ outboundBody = rewriteModel(body, chosenModel);
95
+ } catch (err) {
96
+ fallback = fallback || 'rewrite_error';
97
+ log.warn?.(JSON.stringify({
98
+ event: 'router_fallback',
99
+ reason: 'rewrite_error',
100
+ original_model: originalModel,
101
+ would_have_been: chosenModel,
102
+ error: err.message,
103
+ }));
104
+ outboundBody = body;
105
+ chosenModel = originalModel;
106
+ }
107
+ }
108
+
109
+ if (enabled) {
110
+ log.log?.(JSON.stringify({
111
+ event: 'router_decision',
112
+ tier: decisionTier,
113
+ reason: decisionReason,
114
+ original_model: originalModel,
115
+ chosen_model: chosenModel,
116
+ escalation_skipped: false,
117
+ fallback,
118
+ }));
119
+ }
120
+
121
+ const upstream = await anthropicProxy('/v1/messages', outboundBody, {
122
+ inboundHeaders,
123
+ });
124
+
125
+ if (upstream.stream) {
126
+ const forwardHeaders = {};
127
+ const ct = upstream.headers && upstream.headers['content-type'];
128
+ if (ct) forwardHeaders['Content-Type'] = ct;
129
+ return {
130
+ status: upstream.status,
131
+ stream: upstream.stream,
132
+ headers: forwardHeaders,
133
+ };
134
+ }
135
+
136
+ // First upstream returned non-stream. If it's a 5xx on a router-rewritten
137
+ // request, retry once with the client's original model. This covers the
138
+ // common one-hub/prism case where the chosen tier model has no channel
139
+ // configured — a hard 503 is worse for the caller than a slightly more
140
+ // expensive successful response. The retry may come back streaming when
141
+ // the client originally sent stream:true (the first attempt errored out
142
+ // as JSON before any SSE flowed, so streaming the second attempt is
143
+ // still safe). The result-shape branch below handles both cases.
144
+ let finalUpstream = upstream;
145
+ if (
146
+ enabled
147
+ && upstream.status >= 500
148
+ && chosenModel
149
+ && chosenModel !== originalModel
150
+ ) {
151
+ log.warn?.(JSON.stringify({
152
+ event: 'router_fallback',
153
+ reason: 'upstream_5xx_retry',
154
+ original_model: originalModel,
155
+ would_have_been: chosenModel,
156
+ upstream_status: upstream.status,
157
+ }));
158
+ // Drain the first upstream's body before retrying. fetch Response
159
+ // bodies are single-read streams; if the retry succeeds, finalUpstream
160
+ // moves to the retry response and the original `upstream` body is
161
+ // never consumed, so undici keeps the underlying TCP socket pinned
162
+ // in the awaiting-body state. Under sustained 5xx storms from the
163
+ // rewritten model (the exact scenario this branch targets), every
164
+ // successful retry leaks one socket out of the pool.
165
+ //
166
+ // We don't need the parsed body — text() reads the full body before
167
+ // returning, which is enough to release the socket. If the retry
168
+ // throws, finalUpstream stays pointing at the (now-drained) upstream
169
+ // and the .text() at line 195 short-circuits on the empty Response
170
+ // — but that loses the original 503 body, so cache it here too and
171
+ // restore it on the throw path.
172
+ let drainedFirst = '';
173
+ if (upstream.text) {
174
+ try { drainedFirst = await upstream.text(); } catch { /* socket already gone */ }
175
+ }
176
+ try {
177
+ const retryBody = rewriteModel(body, originalModel);
178
+ finalUpstream = await anthropicProxy('/v1/messages', retryBody, {
179
+ inboundHeaders,
180
+ });
181
+ } catch (err) {
182
+ // Replay the drained first response so the caller still sees the
183
+ // original 503 + body, not an empty stream.
184
+ finalUpstream = {
185
+ status: upstream.status,
186
+ headers: upstream.headers,
187
+ stream: null,
188
+ text: () => drainedFirst,
189
+ };
190
+ log.warn?.(JSON.stringify({
191
+ event: 'router_fallback',
192
+ reason: 'upstream_5xx_retry_failed',
193
+ original_model: originalModel,
194
+ would_have_been: chosenModel,
195
+ error: err.message,
196
+ }));
197
+ }
198
+ }
199
+
200
+ if (finalUpstream.stream) {
201
+ const forwardHeaders = {};
202
+ const ct = finalUpstream.headers && finalUpstream.headers['content-type'];
203
+ if (ct) forwardHeaders['Content-Type'] = ct;
204
+ return {
205
+ status: finalUpstream.status,
206
+ stream: finalUpstream.stream,
207
+ headers: forwardHeaders,
208
+ };
209
+ }
210
+ // Upstream is normally JSON, but a misconfigured local gateway (prism
211
+ // without a route configured), a CDN 4xx page, or a load balancer 502
212
+ // can return text/plain or HTML. Read the body as text (single
213
+ // consumption — fetch Response bodies cannot be read twice) and parse
214
+ // ourselves; on parse failure, wrap the raw text in an {error} envelope
215
+ // so the client gets the real upstream status + a readable body
216
+ // instead of a 500 "Unexpected non-whitespace character".
217
+ // Default to {} not null: src/proxy/server/http.js wraps results as
218
+ // `result.body || result`, and a falsy body would serialize the entire
219
+ // internal {status, body} envelope to the client.
220
+ let respBody = {};
221
+ let raw = '';
222
+ if (finalUpstream.text) {
223
+ try { raw = await finalUpstream.text(); } catch { /* ignore */ }
224
+ }
225
+ if (raw.length > 0) {
226
+ try {
227
+ respBody = JSON.parse(raw);
228
+ } catch {
229
+ respBody = { error: raw };
230
+ log.warn?.(JSON.stringify({
231
+ event: 'router_fallback',
232
+ reason: 'upstream_non_json',
233
+ upstream_status: finalUpstream.status,
234
+ preview: raw.slice(0, 200),
235
+ }));
236
+ }
237
+ }
238
+ return { status: finalUpstream.status, body: respBody };
239
+ };
240
+ }
241
+
242
+ module.exports = { buildMessagesHandler, DEFAULT_TIER_MODELS, resolveTierModels };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ // Phase C of the EvoX multi-tier router. The authoritative algorithm
4
+ // lives in Rust at
5
+ // evox/crates/evox-agent-core/src/model_router.rs
6
+ // and ships a generated JSON fixture at
7
+ // evox/crates/evox-agent-core/tests/fixtures/model_router_cases.json
8
+ // A vendored copy of that fixture (test/fixtures/model_router_cases.json)
9
+ // pins this port byte-for-byte; CI diffs against a regenerated copy so
10
+ // silent drift turns red.
11
+ //
12
+ // Why a fresh module under src/proxy/router/ and not a fold into an
13
+ // existing router-ish helper: src/proxy/ has no prior router/classifier
14
+ // concept (mailbox/, sync/, task/, extensions/, lifecycle/ all sit at a
15
+ // different layer). A first-class subdir is the cheapest place to grow
16
+ // the Phase C pieces (cache_passthrough, anthropic egress) without
17
+ // muddying the proxy core.
18
+
19
+ const REASONS = Object.freeze({
20
+ ROUTER_DISABLED: 'router_disabled',
21
+ HARD_PINNED: 'hard_pinned',
22
+ GENE_HINT: 'gene_hint',
23
+ POST_TOOL_RESULT_SYNTHESIS: 'post_tool_result_synthesis',
24
+ USER_REQUESTED_PLANNING: 'user_requested_planning',
25
+ HIGH_TOOL_USE_DENSITY: 'high_tool_use_density',
26
+ TRIVIAL_LOOKUP: 'trivial_lookup',
27
+ DEFAULT_TIER: 'default_tier',
28
+ ESCALATED_FROM_HISTORY: 'escalated_from_history',
29
+ });
30
+
31
+ const TIER_ORDER = Object.freeze({ cheap: 0, mid: 1, expensive: 2 });
32
+
33
+ function tierStepUp(tier) {
34
+ if (tier === 'cheap') return 'mid';
35
+ if (tier === 'mid') return 'expensive';
36
+ return 'expensive';
37
+ }
38
+
39
+ function classify(features, config) {
40
+ // Parity invariant with model_router.rs:340 — branch order matters.
41
+ // post_tool_result_synthesis must win over high_tool_use_density when
42
+ // both signal: the synthesis turn is just summarising tool output.
43
+ if (
44
+ features.last_user_is_tool_result_only &&
45
+ features.last_assistant_stop_reason === 'ToolUse'
46
+ ) {
47
+ return ['cheap', REASONS.POST_TOOL_RESULT_SYNTHESIS];
48
+ }
49
+ if (features.user_requested_planning) {
50
+ return ['expensive', REASONS.USER_REQUESTED_PLANNING];
51
+ }
52
+ if (features.last_assistant_tool_call_count > 3) {
53
+ return ['expensive', REASONS.HIGH_TOOL_USE_DENSITY];
54
+ }
55
+ if (features.user_simple_lookup) {
56
+ return ['cheap', REASONS.TRIVIAL_LOOKUP];
57
+ }
58
+ return [config.default_tier, REASONS.DEFAULT_TIER];
59
+ }
60
+
61
+ function maybeEscalate(base, history) {
62
+ // Stalled-turn signal from model_router.rs:373: the last decision
63
+ // produced <50 output tokens with no tool call. Bump one tier up,
64
+ // never above expensive, and never weaker than what classify chose.
65
+ if (!history || history.length === 0) return [base, null];
66
+ const last = history[history.length - 1];
67
+ const stalled = last.output_tokens < 50 && !last.had_tool_call;
68
+ if (!stalled) return [base, null];
69
+ if (last.tier === 'expensive') return [base, null];
70
+ const target = tierStepUp(last.tier);
71
+ if (TIER_ORDER[target] > TIER_ORDER[base]) {
72
+ return [target, last.tier];
73
+ }
74
+ return [base, null];
75
+ }
76
+
77
+ function pickForTurn(input) {
78
+ const { features, router_state, config } = input;
79
+ const geneHint = input.gene_hint;
80
+
81
+ if (config.disable) {
82
+ return {
83
+ tier: config.default_tier,
84
+ reason: REASONS.ROUTER_DISABLED,
85
+ escalated_from: null,
86
+ };
87
+ }
88
+ if (config.hard_pin_after_plan && router_state && router_state.pinned) {
89
+ return {
90
+ tier: router_state.pinned,
91
+ reason: REASONS.HARD_PINNED,
92
+ escalated_from: null,
93
+ };
94
+ }
95
+ if (geneHint) {
96
+ return {
97
+ tier: geneHint,
98
+ reason: REASONS.GENE_HINT,
99
+ escalated_from: null,
100
+ };
101
+ }
102
+ const [baseTier, baseReason] = classify(features, config);
103
+ const history = router_state ? router_state.history : [];
104
+ const [escalatedTier, escalatedFrom] = maybeEscalate(baseTier, history);
105
+ const reason = escalatedFrom !== null ? REASONS.ESCALATED_FROM_HISTORY : baseReason;
106
+ return {
107
+ tier: escalatedTier,
108
+ reason,
109
+ escalated_from: escalatedFrom,
110
+ };
111
+ }
112
+
113
+ module.exports = { pickForTurn, REASONS };
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const http = require('http');
4
5
  const { writeSettings, readSettings, clearSettings, clearIfStale } = require('./settings');
5
6
 
@@ -89,10 +90,12 @@ class ProxyHttpServer {
89
90
  this.actualPort = null;
90
91
  this.logger = logger || console;
91
92
  this.server = null;
93
+ this.token = null;
92
94
  }
93
95
 
94
96
  async start() {
95
97
  clearIfStale();
98
+ this.token = crypto.randomBytes(32).toString('hex');
96
99
  this.server = http.createServer((req, res) => this._handleRequest(req, res));
97
100
 
98
101
  let port = this.basePort;
@@ -106,10 +109,11 @@ class ProxyHttpServer {
106
109
  url,
107
110
  pid: process.pid,
108
111
  started_at: new Date().toISOString(),
112
+ token: this.token,
109
113
  },
110
114
  });
111
115
  this.logger.log(`[proxy] HTTP server listening on ${url}`);
112
- return { port, url };
116
+ return { port, url, token: this.token };
113
117
  }
114
118
  port++;
115
119
  }
@@ -125,6 +129,17 @@ class ProxyHttpServer {
125
129
  }
126
130
 
127
131
  async _handleRequest(req, res) {
132
+ const authHeader = req.headers['authorization'] || '';
133
+ const provided = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
134
+ const expBuf = Buffer.from(this.token || '', 'utf8');
135
+ const provBuf = Buffer.from(provided, 'utf8');
136
+ const valid = this.token &&
137
+ provBuf.length === expBuf.length &&
138
+ crypto.timingSafeEqual(provBuf, expBuf);
139
+ if (!valid) {
140
+ return sendJson(res, 401, { error: 'Unauthorized' });
141
+ }
142
+
128
143
  const url = new URL(req.url, `http://127.0.0.1:${this.actualPort}`);
129
144
  const routeKey = `${req.method} ${url.pathname}`;
130
145
 
@@ -139,13 +154,100 @@ class ProxyHttpServer {
139
154
  try {
140
155
  const body = (req.method === 'POST' || req.method === 'PUT') ? await parseBody(req) : {};
141
156
  const query = Object.fromEntries(url.searchParams);
142
- const result = await handler({ body, query, params });
143
- sendJson(res, result.status || 200, result.body || result);
157
+ const headers = req.headers;
158
+ const result = await handler({ body, query, params, headers });
159
+ if (result && result.stream) {
160
+ await this._streamResponse(res, result);
161
+ } else {
162
+ sendJson(res, result.status || 200, result.body || result);
163
+ }
144
164
  } catch (err) {
145
165
  this.logger.error(`[proxy] ${routeKey} error:`, err.message);
146
- sendJson(res, err.statusCode || 500, {
147
- error: err.message || 'Internal error',
148
- });
166
+ if (res.headersSent) {
167
+ try { res.end(); } catch { /* ignore */ }
168
+ } else {
169
+ sendJson(res, err.statusCode || 500, {
170
+ error: err.message || 'Internal error',
171
+ });
172
+ }
173
+ }
174
+ }
175
+
176
+ // SSE / pass-through streaming path used by /v1/messages (slice 5).
177
+ // `result.stream` may be any async iterable yielding Buffer or string
178
+ // chunks (Web ReadableStream from fetch, or a Node Readable). Headers
179
+ // default to text/event-stream so Anthropic-style SSE bytes piped
180
+ // through reach the client unmodified. The caller owns producing
181
+ // correctly-framed SSE; this method only relays bytes.
182
+ async _streamResponse(res, result) {
183
+ const headers = Object.assign({
184
+ 'Content-Type': 'text/event-stream',
185
+ 'Cache-Control': 'no-cache',
186
+ 'Connection': 'keep-alive',
187
+ }, result.headers || {});
188
+ res.writeHead(result.status || 200, headers);
189
+
190
+ // Browsers, network blips, and Ctrl-C all close the SSE socket mid-stream.
191
+ // If we await `drain` without watching for `close`, the drain event never
192
+ // fires on a destroyed socket and this coroutine hangs forever — which
193
+ // also pins the upstream fetch body open (real Anthropic socket leak, not
194
+ // just coroutine leak). For Web ReadableStream upstreams we read via an
195
+ // explicit reader so cancellation can go through the same lock; for sync
196
+ // generators / Node Readables we fall back to for-await.
197
+ const stream = result.stream;
198
+ const reader = stream && typeof stream.getReader === 'function' ? stream.getReader() : null;
199
+
200
+ let clientGone = false;
201
+ const onClose = () => {
202
+ clientGone = true;
203
+ if (reader) {
204
+ reader.cancel().catch(() => { /* upstream already settled */ });
205
+ } else if (stream && typeof stream.destroy === 'function') {
206
+ try { stream.destroy(); } catch { /* ignore */ }
207
+ }
208
+ };
209
+ res.once('close', onClose);
210
+
211
+ const awaitBackpressure = () => new Promise((resolve) => {
212
+ let settled = false;
213
+ const onDrain = () => {
214
+ if (settled) return;
215
+ settled = true;
216
+ res.off('close', onCloseInner);
217
+ resolve();
218
+ };
219
+ const onCloseInner = () => {
220
+ if (settled) return;
221
+ settled = true;
222
+ res.off('drain', onDrain);
223
+ resolve();
224
+ };
225
+ res.once('drain', onDrain);
226
+ res.once('close', onCloseInner);
227
+ });
228
+
229
+ try {
230
+ if (reader) {
231
+ for (;;) {
232
+ const { value, done } = await reader.read();
233
+ if (done || clientGone) break;
234
+ if (!res.write(value)) {
235
+ await awaitBackpressure();
236
+ if (clientGone) break;
237
+ }
238
+ }
239
+ } else {
240
+ for await (const chunk of stream) {
241
+ if (clientGone) break;
242
+ if (!res.write(chunk)) {
243
+ await awaitBackpressure();
244
+ if (clientGone) break;
245
+ }
246
+ }
247
+ }
248
+ } finally {
249
+ res.off('close', onClose);
250
+ try { res.end(); } catch { /* socket may already be destroyed */ }
149
251
  }
150
252
  }
151
253
 
@@ -3,8 +3,8 @@
3
3
  const { PROXY_PROTOCOL_VERSION, SCHEMA_VERSION } = require('../mailbox/store');
4
4
 
5
5
  function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
6
- const { dmHandler, skillUpdater, getHubMailboxStatus, sessionHandler } = extensions || {};
7
- return {
6
+ const { dmHandler, skillUpdater, getHubMailboxStatus, sessionHandler, messagesHandler } = extensions || {};
7
+ const routes = {
8
8
  // -- Mailbox --
9
9
  'POST /mailbox/send': async ({ body }) => {
10
10
  if (!body.type) throw Object.assign(new Error('type is required'), { statusCode: 400 });
@@ -458,6 +458,16 @@ function buildRoutes(store, proxyHandlers, taskMonitor, extensions) {
458
458
  return { body: result };
459
459
  },
460
460
  };
461
+
462
+ // Phase C: only register /v1/messages when the proxy was built with a
463
+ // messages handler (i.e. _proxyAnthropic is available). Slice 5 wires it
464
+ // unconditionally so the route exists on every proxy startup; slice 6 adds
465
+ // EVOMAP_ROUTER_ENABLED behavior inside the handler.
466
+ if (messagesHandler) {
467
+ routes['POST /v1/messages'] = messagesHandler;
468
+ }
469
+
470
+ return routes;
461
471
  }
462
472
 
463
473
  module.exports = { buildRoutes };
@@ -4,34 +4,52 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
 
7
- const SETTINGS_DIR = path.join(os.homedir(), '.evolver');
8
- const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
7
+ // Settings paths are resolved on every call rather than snapshotted at module
8
+ // load. Tests run in parallel by default (`node --test`); when multiple test
9
+ // files exercise the proxy server, each one starting writes
10
+ // `~/.evolver/settings.json` and the sibling webui observer would read those
11
+ // bytes back and report mode='proxy_only' instead of the expected 'idle'.
12
+ // Lazy resolution lets a test set EVOLVER_SETTINGS_DIR to a temp dir before
13
+ // calling start()/readSettings() and stay isolated from concurrent workers.
14
+ function getSettingsDir() {
15
+ return process.env.EVOLVER_SETTINGS_DIR || path.join(os.homedir(), '.evolver');
16
+ }
17
+
18
+ function getSettingsFile() {
19
+ return path.join(getSettingsDir(), 'settings.json');
20
+ }
9
21
 
10
22
  function readSettings() {
11
23
  try {
12
- if (fs.existsSync(SETTINGS_FILE)) {
13
- return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
24
+ const file = getSettingsFile();
25
+ if (fs.existsSync(file)) {
26
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
14
27
  }
15
28
  } catch {}
16
29
  return {};
17
30
  }
18
31
 
19
32
  function writeSettings(data) {
20
- if (!fs.existsSync(SETTINGS_DIR)) {
21
- fs.mkdirSync(SETTINGS_DIR, { recursive: true });
33
+ const dir = getSettingsDir();
34
+ const file = getSettingsFile();
35
+ if (!fs.existsSync(dir)) {
36
+ fs.mkdirSync(dir, { recursive: true });
22
37
  }
23
38
  const current = readSettings();
24
39
  const merged = { ...current, ...data };
25
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2), 'utf8');
40
+ fs.writeFileSync(file, JSON.stringify(merged, null, 2), { encoding: 'utf8', mode: 0o600 });
41
+ // mode: 0o600 only applies on creation; explicitly chmod to tighten pre-existing files
42
+ try { fs.chmodSync(file, 0o600); } catch {}
26
43
  return merged;
27
44
  }
28
45
 
29
46
  function clearSettings() {
30
47
  try {
31
- if (fs.existsSync(SETTINGS_FILE)) {
48
+ const file = getSettingsFile();
49
+ if (fs.existsSync(file)) {
32
50
  const current = readSettings();
33
51
  delete current.proxy;
34
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify(current, null, 2), 'utf8');
52
+ fs.writeFileSync(file, JSON.stringify(current, null, 2), 'utf8');
35
53
  }
36
54
  } catch {}
37
55
  }
@@ -61,4 +79,19 @@ function getProxyUrl() {
61
79
  return settings.proxy?.url || null;
62
80
  }
63
81
 
64
- module.exports = { readSettings, writeSettings, clearSettings, clearIfStale, isStaleProxy, getProxyUrl, SETTINGS_DIR, SETTINGS_FILE };
82
+ function getProxyToken() {
83
+ const settings = readSettings();
84
+ return settings.proxy?.token || null;
85
+ }
86
+
87
+ module.exports = {
88
+ readSettings,
89
+ writeSettings,
90
+ clearSettings,
91
+ clearIfStale,
92
+ isStaleProxy,
93
+ getProxyUrl,
94
+ getProxyToken,
95
+ getSettingsDir,
96
+ getSettingsFile,
97
+ };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { PROXY_PROTOCOL_VERSION } = require('../mailbox/store');
4
4
  const { AuthError } = require('../lifecycle/manager');
5
+ const { hubFetch } = require('../../gep/hubFetch');
5
6
 
6
7
  const DEFAULT_POLL_INTERVAL_ACTIVE = 10_000;
7
8
  const DEFAULT_POLL_INTERVAL_IDLE = 60_000;
@@ -22,7 +23,7 @@ class InboundSync {
22
23
 
23
24
  try {
24
25
  const senderId = this.store.getState('node_id');
25
- const res = await fetch(endpoint, {
26
+ const res = await hubFetch(endpoint, {
26
27
  method: 'POST',
27
28
  headers: this.getHeaders(),
28
29
  body: JSON.stringify({ sender_id: senderId, proxy_protocol_version: PROXY_PROTOCOL_VERSION, cursor, limit }),
@@ -82,7 +83,7 @@ class InboundSync {
82
83
 
83
84
  try {
84
85
  const senderId = this.store.getState('node_id');
85
- await fetch(endpoint, {
86
+ await hubFetch(endpoint, {
86
87
  method: 'POST',
87
88
  headers: this.getHeaders(),
88
89
  body: JSON.stringify({ sender_id: senderId, message_ids: delivered.map(m => m.id) }),
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { PROXY_PROTOCOL_VERSION } = require('../mailbox/store');
4
4
  const { AuthError } = require('../lifecycle/manager');
5
+ const { hubFetch } = require('../../gep/hubFetch');
5
6
 
6
7
  const MAX_BATCH = 50;
7
8
  const MAX_RETRIES = 10;
@@ -22,7 +23,7 @@ class OutboundSync {
22
23
 
23
24
  try {
24
25
  const senderId = this.store.getState('node_id');
25
- const res = await fetch(endpoint, {
26
+ const res = await hubFetch(endpoint, {
26
27
  method: 'POST',
27
28
  headers: this.getHeaders(),
28
29
  body: JSON.stringify({