@melaya/runner 1.1.57 → 1.1.59
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.
- package/dist/assistantHost.py +111 -5
- package/dist/connection.d.ts +1 -1
- package/dist/connection.js +241 -99
- package/dist/detect.js +1 -1
- package/dist/localDbProbe.py +17 -9
- package/dist/pythonEnv.js +10 -9
- package/dist/sharedVendor.js +15 -1
- package/localDbProbe.py +17 -9
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -198,15 +198,22 @@ def _render_summary_text(summary) -> str:
|
|
|
198
198
|
# this to emit delta / tool events keyed to the turn in flight. `cancel` is the
|
|
199
199
|
# STOP flag: set by the stdin-reader thread, polled by the running turn.
|
|
200
200
|
_stream = {"turnId": "", "lens": {}, "cancel": False, "usedBrowser": False,
|
|
201
|
-
"deltaBuf": "", "deltaMid": ""
|
|
201
|
+
"deltaBuf": "", "deltaMid": "", "deltaAt": 0.0,
|
|
202
|
+
"thinkLens": {}, "toolIds": set()}
|
|
202
203
|
|
|
203
204
|
# Coalesce per-token pre_print fragments before emitting a `delta`. agentscope
|
|
204
205
|
# hands us one cumulative snapshot per model chunk; a streaming Anthropic/OpenAI
|
|
205
206
|
# response fragments the answer into 2-15 char pieces, and emitting a MELASSIST
|
|
206
207
|
# line (→ socket → server → SSE → client) PER fragment made streaming read as
|
|
207
208
|
# ultra-choppy / ultra-slow (hundreds of round-trips for one answer). Hold the
|
|
208
|
-
# suffix
|
|
209
|
-
|
|
209
|
+
# suffix briefly, then flush one delta. The flush is TIME-based (every
|
|
210
|
+
# _DELTA_FLUSH_MS once a few chars are buffered) rather than waiting for a whole
|
|
211
|
+
# clause: the old 48-char threshold made the answer appear in visible bursts of
|
|
212
|
+
# ~10 tokens. 35 ms is below what the eye reads as chunky while still merging
|
|
213
|
+
# the 2-15 char fragments a fast model emits in the same instant.
|
|
214
|
+
_DELTA_FLUSH_CHARS = 48 # always flush once this much is buffered
|
|
215
|
+
_DELTA_FLUSH_MIN_CHARS = 6 # time-based flush needs at least this much
|
|
216
|
+
_DELTA_FLUSH_MS = 35
|
|
210
217
|
|
|
211
218
|
# Sentinel returned by a turn's coroutine when the user pressed STOP.
|
|
212
219
|
_CANCELLED = object()
|
|
@@ -465,6 +472,50 @@ def _emit(turn_id: str, kind: str, **fields) -> None:
|
|
|
465
472
|
pass
|
|
466
473
|
|
|
467
474
|
|
|
475
|
+
def _split_stream_blocks(content):
|
|
476
|
+
"""Split a streamed message's content into (answer_text, thinking_text,
|
|
477
|
+
[(tool_id, tool_name)]).
|
|
478
|
+
|
|
479
|
+
The generic _flatten_agent_text folds thinking text AND tool_use `input`
|
|
480
|
+
values into one string. That was harmless while models only streamed text,
|
|
481
|
+
but now that the model wrappers also stream reasoning (Codex summaries) and
|
|
482
|
+
partial tool calls (Claude), flattening would leak reasoning and tool
|
|
483
|
+
arguments into the answer bubble. Typed blocks are routed by type; any other
|
|
484
|
+
shape falls back to the old flattening, so plain-text providers are
|
|
485
|
+
unchanged."""
|
|
486
|
+
from shared.runtime.events import _flatten_agent_text
|
|
487
|
+
if content is None:
|
|
488
|
+
return "", "", []
|
|
489
|
+
if isinstance(content, str):
|
|
490
|
+
return content, "", []
|
|
491
|
+
blocks = content if isinstance(content, (list, tuple)) else [content]
|
|
492
|
+
text_parts: list = []
|
|
493
|
+
think_parts: list = []
|
|
494
|
+
tools: list = []
|
|
495
|
+
for b in blocks:
|
|
496
|
+
btype = b.get("type") if isinstance(b, dict) else getattr(b, "type", None)
|
|
497
|
+
if btype == "text":
|
|
498
|
+
v = b.get("text") if isinstance(b, dict) else getattr(b, "text", "")
|
|
499
|
+
if v:
|
|
500
|
+
text_parts.append(str(v))
|
|
501
|
+
elif btype == "thinking":
|
|
502
|
+
v = b.get("thinking") if isinstance(b, dict) else getattr(b, "thinking", "")
|
|
503
|
+
if v:
|
|
504
|
+
think_parts.append(str(v))
|
|
505
|
+
elif btype == "tool_use":
|
|
506
|
+
tid = b.get("id") if isinstance(b, dict) else getattr(b, "id", "")
|
|
507
|
+
nm = b.get("name") if isinstance(b, dict) else getattr(b, "name", "")
|
|
508
|
+
if nm:
|
|
509
|
+
tools.append((str(tid or nm), str(nm)))
|
|
510
|
+
elif btype in ("image", "audio", "video", "tool_result"):
|
|
511
|
+
continue
|
|
512
|
+
else:
|
|
513
|
+
v = _flatten_agent_text(b)
|
|
514
|
+
if v:
|
|
515
|
+
text_parts.append(v)
|
|
516
|
+
return "".join(text_parts), "".join(think_parts), tools
|
|
517
|
+
|
|
518
|
+
|
|
468
519
|
def _flush_delta(turn_id: str) -> None:
|
|
469
520
|
"""Emit any buffered pre_print suffix as one `delta` and clear the buffer.
|
|
470
521
|
|
|
@@ -475,6 +526,7 @@ def _flush_delta(turn_id: str) -> None:
|
|
|
475
526
|
if buf and turn_id:
|
|
476
527
|
_emit(turn_id, "delta", content=buf)
|
|
477
528
|
_stream["deltaBuf"] = ""
|
|
529
|
+
_stream["deltaAt"] = time.monotonic()
|
|
478
530
|
|
|
479
531
|
|
|
480
532
|
def _log(msg: str) -> None:
|
|
@@ -1022,7 +1074,24 @@ def _register_stream_hooks(agent) -> None:
|
|
|
1022
1074
|
if msg is None:
|
|
1023
1075
|
return
|
|
1024
1076
|
mid = str(getattr(msg, "id", "") or id(msg))
|
|
1025
|
-
text =
|
|
1077
|
+
text, thinking, tool_uses = _split_stream_blocks(getattr(msg, "content", msg))
|
|
1078
|
+
# Reasoning (Codex reasoning summaries, Claude thinking) streams to the
|
|
1079
|
+
# thinking strip as `think`, never into the answer bubble.
|
|
1080
|
+
if thinking:
|
|
1081
|
+
tprev = _stream["thinkLens"].get(mid, 0)
|
|
1082
|
+
if len(thinking) > tprev:
|
|
1083
|
+
_stream["thinkLens"][mid] = len(thinking)
|
|
1084
|
+
_emit(tid, "think", content=thinking[tprev:])
|
|
1085
|
+
# A tool call shows its chip as soon as the model STARTS writing it
|
|
1086
|
+
# (Claude streams tool arguments, which can take a while for a long
|
|
1087
|
+
# file or message), not only after the tool has run. post_acting
|
|
1088
|
+
# skips ids already announced here.
|
|
1089
|
+
for _tid, _tname in tool_uses:
|
|
1090
|
+
if _tid not in _stream["toolIds"]:
|
|
1091
|
+
_stream["toolIds"].add(_tid)
|
|
1092
|
+
if _tname.startswith("browser_"):
|
|
1093
|
+
_stream["usedBrowser"] = True
|
|
1094
|
+
_emit(tid, "tool", name=_tname)
|
|
1026
1095
|
if not text:
|
|
1027
1096
|
return
|
|
1028
1097
|
prev = _stream["lens"].get(mid, 0)
|
|
@@ -1046,7 +1115,10 @@ def _register_stream_hooks(agent) -> None:
|
|
|
1046
1115
|
_stream["deltaBuf"] = "\n\n"
|
|
1047
1116
|
_stream["deltaMid"] = mid
|
|
1048
1117
|
_stream["deltaBuf"] = _stream.get("deltaBuf", "") + suffix
|
|
1049
|
-
|
|
1118
|
+
_blen = len(_stream["deltaBuf"])
|
|
1119
|
+
_waited_ms = (time.monotonic() - float(_stream.get("deltaAt") or 0.0)) * 1000.0
|
|
1120
|
+
if (_blen >= _DELTA_FLUSH_CHARS or "\n" in suffix
|
|
1121
|
+
or (_blen >= _DELTA_FLUSH_MIN_CHARS and _waited_ms >= _DELTA_FLUSH_MS)):
|
|
1050
1122
|
_flush_delta(tid)
|
|
1051
1123
|
except Exception:
|
|
1052
1124
|
pass
|
|
@@ -1066,6 +1138,9 @@ def _register_stream_hooks(agent) -> None:
|
|
|
1066
1138
|
tcs = [tc] if tc is not None else []
|
|
1067
1139
|
for tc in tcs:
|
|
1068
1140
|
name = tc.get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
|
|
1141
|
+
_tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
|
|
1142
|
+
if name and str(_tc_id or name) in _stream.get("toolIds", set()):
|
|
1143
|
+
continue # chip already shown while the call was streaming
|
|
1069
1144
|
if name:
|
|
1070
1145
|
# Browser action events ride the same `tool` stream (the
|
|
1071
1146
|
# client renders browser_* names as live action chips on the
|
|
@@ -1134,6 +1209,8 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
|
|
|
1134
1209
|
_emit(turn_id, "round", n=1)
|
|
1135
1210
|
_stream["turnId"] = turn_id
|
|
1136
1211
|
_stream["lens"] = {}
|
|
1212
|
+
_stream["thinkLens"] = {}
|
|
1213
|
+
_stream["toolIds"] = set()
|
|
1137
1214
|
_stream["deltaBuf"] = "" # fresh turn — drop any stale streamed suffix
|
|
1138
1215
|
_stream["deltaMid"] = ""
|
|
1139
1216
|
_stream["cancel"] = False # fresh turn — clear any stale STOP
|
|
@@ -1260,6 +1337,7 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
|
|
|
1260
1337
|
|
|
1261
1338
|
|
|
1262
1339
|
def main() -> int:
|
|
1340
|
+
global _GENERATION
|
|
1263
1341
|
_boot_t0 = time.perf_counter()
|
|
1264
1342
|
_log(f"booting (provider={os.environ.get('MEL_ASSISTANT_PROVIDER')})")
|
|
1265
1343
|
# Wire OpenTelemetry → Melaya event relay so agentscope's @trace_llm actually
|
|
@@ -1306,6 +1384,20 @@ def main() -> int:
|
|
|
1306
1384
|
_emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION, configHash=_config_hash())
|
|
1307
1385
|
_log(f"[timing] ready — {time.perf_counter() - _boot_t0:.2f}s (main start → ready). Import cost = gap from process spawn to the 'booting' line above.")
|
|
1308
1386
|
|
|
1387
|
+
# Warm the provider's per-process caches (Claude Code User-Agent, /v1/models
|
|
1388
|
+
# alias lookup) in the background, so the FIRST turn does not pay them.
|
|
1389
|
+
try:
|
|
1390
|
+
from shared.runtime.model import prewarm_cli_provider
|
|
1391
|
+
_pw_provider = os.environ.get("MEL_ASSISTANT_PROVIDER", "")
|
|
1392
|
+
if _pw_provider in ("claude_code", "codex"):
|
|
1393
|
+
threading.Thread(
|
|
1394
|
+
target=prewarm_cli_provider,
|
|
1395
|
+
args=(_pw_provider, os.environ.get("MEL_ASSISTANT_MODEL", "")),
|
|
1396
|
+
daemon=True,
|
|
1397
|
+
).start()
|
|
1398
|
+
except Exception:
|
|
1399
|
+
_log("provider prewarm unavailable (non-fatal)")
|
|
1400
|
+
|
|
1309
1401
|
q: "queue.Queue[str]" = queue.Queue()
|
|
1310
1402
|
threading.Thread(target=_stdin_reader, args=(q,), daemon=True).start()
|
|
1311
1403
|
|
|
@@ -1326,6 +1418,20 @@ def main() -> int:
|
|
|
1326
1418
|
except Exception:
|
|
1327
1419
|
_log(f"bad stdin line (not json): {line[:120]}")
|
|
1328
1420
|
continue
|
|
1421
|
+
# Warm-pool adoption: the runner re-points a pre-booted spare to a new
|
|
1422
|
+
# conversation and stamps that conversation's DB generation here, so the
|
|
1423
|
+
# generation-bound `restore` below accepts its rehydrate frame. Only a
|
|
1424
|
+
# spare that never ran a turn (empty memory) may be re-stamped.
|
|
1425
|
+
if isinstance(req, dict) and req.get("kind") == "adopt":
|
|
1426
|
+
try:
|
|
1427
|
+
new_gen = int(req.get("generation") or 0)
|
|
1428
|
+
except Exception:
|
|
1429
|
+
new_gen = _GENERATION
|
|
1430
|
+
if _memory_watermark(agent) == 0:
|
|
1431
|
+
_GENERATION = new_gen
|
|
1432
|
+
else:
|
|
1433
|
+
_log(f"adopt ignored: host already has memory (gen stays {_GENERATION})")
|
|
1434
|
+
continue
|
|
1329
1435
|
# PR4: generation-bound rehydrate. Seed a FRESH host's memory from the
|
|
1330
1436
|
# server snapshot (task pin + summary + recent tail) via restoreSnapshot —
|
|
1331
1437
|
# watermark-guarded so a re-offer never double-seeds. Never memory.add().
|
package/dist/connection.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Heartbeat to keep the connection alive
|
|
11
11
|
* - Graceful disconnect on Ctrl+C
|
|
12
12
|
*/
|
|
13
|
-
import type
|
|
13
|
+
import { type DetectedModel } from "./detect.js";
|
|
14
14
|
interface ConnectOpts {
|
|
15
15
|
token: string;
|
|
16
16
|
serverUrl: string;
|
package/dist/connection.js
CHANGED
|
@@ -25,6 +25,7 @@ import { startLocalRelay, setActiveProject } from "./localRelay.js";
|
|
|
25
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
26
|
const __dirname = dirname(__filename);
|
|
27
27
|
import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
|
|
28
|
+
import { detectModels } from "./detect.js";
|
|
28
29
|
import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
|
|
29
30
|
// Melaya Browser (plan Sections 3/4/7): the governed browser control
|
|
30
31
|
// bridge + its runner-side grant verifier and engine discovery.
|
|
@@ -76,6 +77,10 @@ let warmSpare = null;
|
|
|
76
77
|
// the most recent real assistant_start so the spare matches the user's actual
|
|
77
78
|
// usage. null until the first real boot teaches us a config.
|
|
78
79
|
let warmSpareSeed = null;
|
|
80
|
+
function _assistantCredHash(c) {
|
|
81
|
+
const entries = Object.entries(c || {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
82
|
+
return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 16);
|
|
83
|
+
}
|
|
79
84
|
// PR4: sid-keyed boot reservation. The guard-then-spawn in runner:assistant_start
|
|
80
85
|
// has an async gap (ensureSharedModules / ensurePythonEnv awaits) between the
|
|
81
86
|
// activeAssistants.has() check and the .set(). A second assistant_start in that
|
|
@@ -177,10 +182,38 @@ export async function connect(opts) {
|
|
|
177
182
|
// first assistant chat spawns near-instantly. No-op on a fresh runner (no
|
|
178
183
|
// shared bundle on disk yet) — the real assistant_start installs it then.
|
|
179
184
|
let _assistantPrewarmed = false;
|
|
185
|
+
// Resolve the installed Claude Code version ONCE per runner process and export
|
|
186
|
+
// it as MEL_CLAUDE_CODE_VERSION, which every assistant host inherits. Without
|
|
187
|
+
// it each host ran a blocking `claude --version` (slow on Windows, through a
|
|
188
|
+
// .cmd shim) on its first model call. Fixed argv, no user input.
|
|
189
|
+
const _detectClaudeCodeVersion = async () => {
|
|
190
|
+
if (process.env.MEL_CLAUDE_CODE_VERSION)
|
|
191
|
+
return;
|
|
192
|
+
const out = await new Promise((resolve) => {
|
|
193
|
+
let buf = "";
|
|
194
|
+
try {
|
|
195
|
+
const p = spawn("claude", ["--version"], { shell: process.platform === "win32", stdio: ["ignore", "pipe", "ignore"] });
|
|
196
|
+
const timer = setTimeout(() => { try {
|
|
197
|
+
p.kill();
|
|
198
|
+
}
|
|
199
|
+
catch { /* gone */ } resolve(buf); }, 15_000);
|
|
200
|
+
p.stdout?.on("data", (d) => { buf += String(d); });
|
|
201
|
+
p.on("error", () => { clearTimeout(timer); resolve(""); });
|
|
202
|
+
p.on("close", () => { clearTimeout(timer); resolve(buf); });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
resolve("");
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
const m = out.match(/\d+\.\d+\.\d+/);
|
|
209
|
+
if (m)
|
|
210
|
+
process.env.MEL_CLAUDE_CODE_VERSION = m[0];
|
|
211
|
+
};
|
|
180
212
|
const _prewarmAssistantEnv = async () => {
|
|
181
213
|
if (_assistantPrewarmed)
|
|
182
214
|
return;
|
|
183
215
|
_assistantPrewarmed = true;
|
|
216
|
+
void _detectClaudeCodeVersion();
|
|
184
217
|
try {
|
|
185
218
|
const { getLocalSharedVersion } = await import("./sharedVendor.js");
|
|
186
219
|
let localVersion = getLocalSharedVersion();
|
|
@@ -254,6 +287,30 @@ export async function connect(opts) {
|
|
|
254
287
|
console.log(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
|
|
255
288
|
}
|
|
256
289
|
};
|
|
290
|
+
// Models were detected ONCE at startup, so a model a CLI added later (e.g.
|
|
291
|
+
// gpt-6-astra landing in ~/.codex/models_cache.json after the runner started,
|
|
292
|
+
// 2026-10-03) never reached the picker until a runner restart. Re-detect every
|
|
293
|
+
// 5 minutes and push the list when it changed. File reads only (plus the
|
|
294
|
+
// Claude model list call), no subprocess.
|
|
295
|
+
let modelRefresh = null;
|
|
296
|
+
const startModelRefresh = () => {
|
|
297
|
+
if (modelRefresh)
|
|
298
|
+
return;
|
|
299
|
+
modelRefresh = setInterval(async () => {
|
|
300
|
+
try {
|
|
301
|
+
const fresh = await detectModels();
|
|
302
|
+
if (JSON.stringify(fresh) === JSON.stringify(opts.models))
|
|
303
|
+
return;
|
|
304
|
+
opts.models = fresh;
|
|
305
|
+
if (socket.connected)
|
|
306
|
+
socket.emit("runner:models", fresh);
|
|
307
|
+
if (opts.verbose)
|
|
308
|
+
console.log(chalk.gray(` [models] refreshed: ${fresh.length} model(s)`));
|
|
309
|
+
}
|
|
310
|
+
catch { /* keep the last good list */ }
|
|
311
|
+
}, 5 * 60_000);
|
|
312
|
+
modelRefresh.unref?.();
|
|
313
|
+
};
|
|
257
314
|
socket.on("connect", async () => {
|
|
258
315
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
259
316
|
connectedAt = Date.now();
|
|
@@ -264,6 +321,7 @@ export async function connect(opts) {
|
|
|
264
321
|
socketGeneration += 1;
|
|
265
322
|
// Report detected models
|
|
266
323
|
socket.emit("runner:models", opts.models);
|
|
324
|
+
startModelRefresh();
|
|
267
325
|
// Discover installed browser engines (Chrome/Edge/Brave/bundled
|
|
268
326
|
// Chromium) so the hello can advertise the browser_control capability
|
|
269
327
|
// with an accurate engine + CDP report (plan Section 4). Best-effort:
|
|
@@ -1534,7 +1592,10 @@ export async function connect(opts) {
|
|
|
1534
1592
|
// (claude_code/codex keep a per-user CLI/OAuth session) can contend when a
|
|
1535
1593
|
// spare runs a turn alongside a live host — needs per-provider validation
|
|
1536
1594
|
// before it's on for everyone. Set MEL_ASSISTANT_WARM_POOL=1 to enable.
|
|
1537
|
-
|
|
1595
|
+
// ON by default since the providers call the HTTP APIs directly with the
|
|
1596
|
+
// token read from disk (no CLI session to contend). MEL_ASSISTANT_WARM_POOL=0
|
|
1597
|
+
// turns it off.
|
|
1598
|
+
const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL !== "0";
|
|
1538
1599
|
let _prewarmInFlight = false;
|
|
1539
1600
|
const schedulePrewarm = () => {
|
|
1540
1601
|
if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
|
|
@@ -1565,6 +1626,7 @@ export async function connect(opts) {
|
|
|
1565
1626
|
// spare-specific id broke turn execution on adoption.
|
|
1566
1627
|
const spareEnv = { ...seed.env };
|
|
1567
1628
|
const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
|
|
1629
|
+
spare.credHash = seed.credHash;
|
|
1568
1630
|
warmSpare = spare;
|
|
1569
1631
|
const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
|
|
1570
1632
|
try {
|
|
@@ -1572,7 +1634,7 @@ export async function connect(opts) {
|
|
|
1572
1634
|
}
|
|
1573
1635
|
catch { /* gone */ }
|
|
1574
1636
|
warmSpare = null;
|
|
1575
|
-
} },
|
|
1637
|
+
} }, 30 * 60_000);
|
|
1576
1638
|
t.unref?.();
|
|
1577
1639
|
}
|
|
1578
1640
|
catch (e) {
|
|
@@ -1583,6 +1645,168 @@ export async function connect(opts) {
|
|
|
1583
1645
|
_prewarmInFlight = false;
|
|
1584
1646
|
}
|
|
1585
1647
|
};
|
|
1648
|
+
// The assistant host's environment for a session (or a warm spare). Shared by
|
|
1649
|
+
// runner:assistant_start and runner:assistant_prewarm so a pre-booted spare
|
|
1650
|
+
// runs under exactly the env a real start would build.
|
|
1651
|
+
const buildAssistantEnv = async (payload, sid, generation) => {
|
|
1652
|
+
const sharedDir = getSharedDir();
|
|
1653
|
+
const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
|
|
1654
|
+
const sslEnv = certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
|
|
1655
|
+
const httpBase = opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
1656
|
+
const env = {
|
|
1657
|
+
...process.env,
|
|
1658
|
+
PYTHONPATH: sharedDir,
|
|
1659
|
+
PYTHONIOENCODING: "utf-8",
|
|
1660
|
+
PYTHONUTF8: "1",
|
|
1661
|
+
...sslEnv,
|
|
1662
|
+
MEL_STUDIO_URL: httpBase,
|
|
1663
|
+
MELAYA_API_BASE: httpBase,
|
|
1664
|
+
MELAYA_API_URL: httpBase,
|
|
1665
|
+
LMSTUDIO_BASE_URL: "http://127.0.0.1:1234",
|
|
1666
|
+
OLLAMA_BASE_URL: "http://127.0.0.1:11434",
|
|
1667
|
+
MEL_ASSISTANT_PROVIDER: String(payload.provider || "claude_code"),
|
|
1668
|
+
MEL_ASSISTANT_MODEL: String(payload.model || ""),
|
|
1669
|
+
MEL_ASSISTANT_LANGUAGE: String(payload.language || "en"),
|
|
1670
|
+
MEL_ASSISTANT_SURFACE: String(payload.surface || ""),
|
|
1671
|
+
// Product attribution is deliberately separate from MEL_ASSISTANT_SURFACE,
|
|
1672
|
+
// which controls runtime capabilities such as mobile-native phone tools.
|
|
1673
|
+
MEL_ASSISTANT_TELEMETRY_SURFACE: String(payload.telemetrySurface || "assistant"),
|
|
1674
|
+
// Phone toolkit unlocks when the user has a paired phone, on ANY surface
|
|
1675
|
+
// (desktop drives the paired phone remotely). The host ORs this with the
|
|
1676
|
+
// mobile-native surface check. Empty ⇒ not paired ⇒ no phone tools.
|
|
1677
|
+
MEL_ASSISTANT_PHONE_READY: payload.phoneReady ? "1" : "",
|
|
1678
|
+
// Melaya Browser (plan 0.4): the CAPABILITY flag. Presence means the
|
|
1679
|
+
// runner advertises browser control and the browser toolkit should be
|
|
1680
|
+
// included in the host's toolkit. The TARGET GRANT is NEVER in the boot
|
|
1681
|
+
// env; it arrives per-turn on the turn frame and is purged at turn end.
|
|
1682
|
+
// This is part of the config hash so the host reboots if capability
|
|
1683
|
+
// presence changes (a runner that gains/loses browser control restarts
|
|
1684
|
+
// the host automatically via config-drift detection above). MUST follow
|
|
1685
|
+
// payload.browserCapable, not a hardcoded "1": the host's _config_hash
|
|
1686
|
+
// appends "browser" only when this is set, and the server's hash does the
|
|
1687
|
+
// same only when cfg.browserCapable is true. Hardcoding "1" made the host
|
|
1688
|
+
// always hash "browser" while a non-browser server hash omitted it, which
|
|
1689
|
+
// pinned every turn to a permanent runner_host_config_stale.
|
|
1690
|
+
MEL_ASSISTANT_BROWSER_CAPABLE: payload.browserCapable ? "1" : "",
|
|
1691
|
+
// Connector tool sets the user enabled for this chat (comma-joined service
|
|
1692
|
+
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
1693
|
+
// are reachable without exploding context.
|
|
1694
|
+
MEL_ASSISTANT_CONNECTORS: Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
|
|
1695
|
+
// Surgical tool pinning: exact connector tool NAMES (not just service ids)
|
|
1696
|
+
// the server already resolved creds for. The host registers these ACTIVE
|
|
1697
|
+
// at boot instead of dumping them into the deferred pool, eliminating the
|
|
1698
|
+
// search_tools -> activate_tool round-trip for known marketing tools.
|
|
1699
|
+
// Byte-for-byte source of truth for _config_hash's pinned-tools field.
|
|
1700
|
+
MEL_ASSISTANT_PINNED_TOOLS: payload.pinnedTools?.join(",") ?? "",
|
|
1701
|
+
// Gate write connector-tools behind the in-chat approval card (fail-safe).
|
|
1702
|
+
MEL_ASSISTANT_CONNECTOR_HITL: Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
|
|
1703
|
+
// HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
|
|
1704
|
+
// reads this at start; each turn's frame can flip it (see assistant_turn).
|
|
1705
|
+
// Fail-safe: unknown ⇒ "safe".
|
|
1706
|
+
MEL_ASSISTANT_HITL_MODE: (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe",
|
|
1707
|
+
// PR4: the DB-authoritative generation this host boots under. The host
|
|
1708
|
+
// echoes it in `ready` and stamps events; an older boot self-terminates
|
|
1709
|
+
// on a generation mismatch.
|
|
1710
|
+
MEL_ASSISTANT_GENERATION: String(generation),
|
|
1711
|
+
// Run context for phone-driving assistant turns. The assistant host has
|
|
1712
|
+
// no pipeline MEL_RUN_ID, so without this phone.py._cmd omits `run_id`,
|
|
1713
|
+
// the server's arm block (phone.ts, `if (runId)`) never fires, and the
|
|
1714
|
+
// device never receives `hitlMode` — Autonomous still shows publish
|
|
1715
|
+
// cards on the runner-assistant surface. Use the SAME stable
|
|
1716
|
+
// `assistant:{userId}` runId the cloud assistant path uses (sid is
|
|
1717
|
+
// `userId:conversationId`) so the arm / kill / active-run plumbing stays
|
|
1718
|
+
// consistent across surfaces. assistantHost mirrors MEL_ASSISTANT_HITL_MODE
|
|
1719
|
+
// → MEL_HITL_MODE so phone.py stamps the mode on the command body.
|
|
1720
|
+
MEL_RUN_ID: `assistant:${sid.split(":")[0]}`,
|
|
1721
|
+
// Span tracing → agents.spans. tracing_exporter.MelayaSpanExporter only
|
|
1722
|
+
// exports when BOTH MEL_RUN_ID and MEL_BUILDER_URL are present (it POSTs
|
|
1723
|
+
// batches through this same local relay the pipeline spawns use, lines
|
|
1724
|
+
// ~463/853). The assistant host was missing these two, so — even with
|
|
1725
|
+
// @trace_llm wired — every claude_code/codex assistant turn recorded ZERO
|
|
1726
|
+
// token spans. Spans land under conversation_id = MEL_RUN_ID
|
|
1727
|
+
// (`assistant:<userId>`); the server trigger resolves user_id from it.
|
|
1728
|
+
MEL_BUILDER_URL: `http://127.0.0.1:${relay.port}`,
|
|
1729
|
+
MEL_RELAY_NONCE: relay.nonce,
|
|
1730
|
+
MEL_PIPELINE_NAME: "Melaya · Assistant",
|
|
1731
|
+
// Luma browser bridge (same injection as pipeline runs, line ~431): lets
|
|
1732
|
+
// shared/tools/luma.py route /event/register through Playwright and bypass
|
|
1733
|
+
// Cloudflare's write rule. Reads keep using aiohttp. Empty when no signed-in
|
|
1734
|
+
// Luma session exists (bridge not started) — reads still work, writes 403.
|
|
1735
|
+
...(lumaBridge
|
|
1736
|
+
? { MEL_LUMA_BROWSER_URL: lumaBridge.url, MEL_LUMA_BROWSER_TOKEN: lumaBridge.token }
|
|
1737
|
+
: {}),
|
|
1738
|
+
// Per-user creds (incl. MELAYA_API_KEY + the selected connectors' env).
|
|
1739
|
+
...(payload.credentials || {}),
|
|
1740
|
+
// laya-local (decide_* built-in tools, always-on core): the runner can't
|
|
1741
|
+
// see the box's private 127.0.0.1:8770, so point LAYA_LOCAL_URL at the
|
|
1742
|
+
// server's PER-USER-gated proxy. AFTER creds so it can't be overridden.
|
|
1743
|
+
// The proxy authenticates with this user's MELAYA_API_KEY (in the
|
|
1744
|
+
// projected creds) - never a shared secret - and meters per user.
|
|
1745
|
+
LAYA_LOCAL_URL: httpBase.replace(/\/$/, "") + "/api/v1/internal/laya",
|
|
1746
|
+
};
|
|
1747
|
+
return env;
|
|
1748
|
+
};
|
|
1749
|
+
// A warm spare whose shared bundle was replaced underneath it would lazily
|
|
1750
|
+
// import NEW modules into OLD code; drop it when the bundle on disk changes.
|
|
1751
|
+
let _spareBundle = "";
|
|
1752
|
+
const _dropSpareOnOlderBundle = () => {
|
|
1753
|
+
void import("./sharedVendor.js").then(({ getLocalSharedVersion }) => {
|
|
1754
|
+
const v = getLocalSharedVersion() || "";
|
|
1755
|
+
if (_spareBundle && v && v !== _spareBundle && warmSpare && !warmSpare.forward) {
|
|
1756
|
+
try {
|
|
1757
|
+
warmSpare.proc.kill();
|
|
1758
|
+
}
|
|
1759
|
+
catch { /* gone */ }
|
|
1760
|
+
warmSpare = null;
|
|
1761
|
+
}
|
|
1762
|
+
_spareBundle = v;
|
|
1763
|
+
}).catch(() => { });
|
|
1764
|
+
};
|
|
1765
|
+
// Server push: download a new shared bundle in the background (after an
|
|
1766
|
+
// agents deploy, and on connect) so the next chat does not pay for it.
|
|
1767
|
+
socket.on("runner:prefetch_bundle", (p) => {
|
|
1768
|
+
const v = String(p?.sharedVersion || "");
|
|
1769
|
+
if (!v)
|
|
1770
|
+
return;
|
|
1771
|
+
void ensureSharedModules(opts.serverUrl, v, opts.token)
|
|
1772
|
+
.then(() => { _dropSpareOnOlderBundle(); if (opts.verbose)
|
|
1773
|
+
console.log(chalk.gray(` [assistant] shared bundle ${v} prefetched`)); })
|
|
1774
|
+
.catch((e) => { if (opts.verbose)
|
|
1775
|
+
console.log(chalk.gray(` [assistant] bundle prefetch failed: ${e?.message || e}`)); });
|
|
1776
|
+
});
|
|
1777
|
+
// Server push on connect: pre-boot a warm spare for the user's LAST assistant
|
|
1778
|
+
// config, so even the first chat after a runner (re)start adopts a ready host.
|
|
1779
|
+
// Builds the exact env a real start would; adoption still requires the same
|
|
1780
|
+
// config and credentials, so a stale spare is simply never used.
|
|
1781
|
+
socket.on("runner:assistant_prewarm", async (payload) => {
|
|
1782
|
+
if (!WARM_POOL_ENABLED || warmSpare || _prewarmInFlight)
|
|
1783
|
+
return;
|
|
1784
|
+
if (!payload?.userId || !payload?.configHash)
|
|
1785
|
+
return;
|
|
1786
|
+
try {
|
|
1787
|
+
const sharedVersion = String(payload.sharedVersion ?? "latest");
|
|
1788
|
+
await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
|
|
1789
|
+
_dropSpareOnOlderBundle();
|
|
1790
|
+
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
1791
|
+
const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
|
|
1792
|
+
console.log(chalk.gray(` [assistant prewarm] ${m}`)); });
|
|
1793
|
+
if (!envResult.ok)
|
|
1794
|
+
return;
|
|
1795
|
+
const hostCandidates = [join(__dirname, "assistantHost.py"), join(__dirname, "..", "assistantHost.py")];
|
|
1796
|
+
const hostScript = hostCandidates.find((c) => existsSync(c)) || "";
|
|
1797
|
+
if (!hostScript)
|
|
1798
|
+
return;
|
|
1799
|
+
const env = await buildAssistantEnv({ ...payload, sessionId: `${payload.userId}:__prewarm__` }, `${payload.userId}:__prewarm__`, 0);
|
|
1800
|
+
warmSpareSeed = { env, configHash: String(payload.configHash), generation: 0, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript, credHash: _assistantCredHash(payload.credentials) };
|
|
1801
|
+
schedulePrewarm();
|
|
1802
|
+
if (opts.verbose)
|
|
1803
|
+
console.log(chalk.gray(` [assistant] warm host pre-booting (${payload.provider})`));
|
|
1804
|
+
}
|
|
1805
|
+
catch (e) {
|
|
1806
|
+
if (opts.verbose)
|
|
1807
|
+
console.log(chalk.gray(` [assistant] prewarm skipped: ${e?.message || e}`));
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1586
1810
|
socket.on("runner:assistant_start", async (payload) => {
|
|
1587
1811
|
const sid = String(payload.sessionId || "");
|
|
1588
1812
|
if (!sid)
|
|
@@ -1623,11 +1847,22 @@ export async function connect(opts) {
|
|
|
1623
1847
|
// immediately, skipping the ~7s cold boot (import + build_agent). Fail-safe:
|
|
1624
1848
|
// exact match of a READY, un-killed spare ONLY; anything else falls straight
|
|
1625
1849
|
// through to the fresh-boot path below, so the pool can never break a session.
|
|
1850
|
+
// The spare must have the same config AND the same credentials, and must
|
|
1851
|
+
// never have run a turn (empty memory). Its generation is re-stamped to this
|
|
1852
|
+
// conversation's via an `adopt` frame, so a restored conversation's
|
|
1853
|
+
// generation-bound rehydrate still applies (the old exact-generation match
|
|
1854
|
+
// meant a spare could almost never be adopted).
|
|
1626
1855
|
if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
|
|
1627
1856
|
&& warmSpare && warmSpare.ready && !warmSpare.killed && expectedConfig
|
|
1628
|
-
&& warmSpare.configHash === expectedConfig && warmSpare.
|
|
1857
|
+
&& warmSpare.configHash === expectedConfig && (warmSpare.memoryWatermark || 0) === 0
|
|
1858
|
+
&& warmSpare.credHash === _assistantCredHash(payload.credentials)) {
|
|
1629
1859
|
const spare = warmSpare;
|
|
1630
1860
|
warmSpare = null;
|
|
1861
|
+
try {
|
|
1862
|
+
spare.proc.stdin?.write(JSON.stringify({ kind: "adopt", generation }) + "\n");
|
|
1863
|
+
}
|
|
1864
|
+
catch { /* host gone: falls to session_closed */ }
|
|
1865
|
+
spare.generation = generation;
|
|
1631
1866
|
spare.sid = sid;
|
|
1632
1867
|
spare.forward = true;
|
|
1633
1868
|
spare.lastActivity = Date.now();
|
|
@@ -1645,6 +1880,7 @@ export async function connect(opts) {
|
|
|
1645
1880
|
try {
|
|
1646
1881
|
const sharedVersion = String(payload.sharedVersion ?? "latest");
|
|
1647
1882
|
await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
|
|
1883
|
+
_dropSpareOnOlderBundle();
|
|
1648
1884
|
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
1649
1885
|
const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
|
|
1650
1886
|
console.log(chalk.gray(` [assistant venv] ${m}`)); });
|
|
@@ -1674,101 +1910,7 @@ export async function connect(opts) {
|
|
|
1674
1910
|
if (Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
|
|
1675
1911
|
await _ensureLumaBridge();
|
|
1676
1912
|
}
|
|
1677
|
-
const
|
|
1678
|
-
const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
|
|
1679
|
-
const sslEnv = certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
|
|
1680
|
-
const httpBase = opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
1681
|
-
const env = {
|
|
1682
|
-
...process.env,
|
|
1683
|
-
PYTHONPATH: sharedDir,
|
|
1684
|
-
PYTHONIOENCODING: "utf-8",
|
|
1685
|
-
PYTHONUTF8: "1",
|
|
1686
|
-
...sslEnv,
|
|
1687
|
-
MEL_STUDIO_URL: httpBase,
|
|
1688
|
-
MELAYA_API_BASE: httpBase,
|
|
1689
|
-
MELAYA_API_URL: httpBase,
|
|
1690
|
-
LMSTUDIO_BASE_URL: "http://127.0.0.1:1234",
|
|
1691
|
-
OLLAMA_BASE_URL: "http://127.0.0.1:11434",
|
|
1692
|
-
MEL_ASSISTANT_PROVIDER: String(payload.provider || "claude_code"),
|
|
1693
|
-
MEL_ASSISTANT_MODEL: String(payload.model || ""),
|
|
1694
|
-
MEL_ASSISTANT_LANGUAGE: String(payload.language || "en"),
|
|
1695
|
-
MEL_ASSISTANT_SURFACE: String(payload.surface || ""),
|
|
1696
|
-
// Product attribution is deliberately separate from MEL_ASSISTANT_SURFACE,
|
|
1697
|
-
// which controls runtime capabilities such as mobile-native phone tools.
|
|
1698
|
-
MEL_ASSISTANT_TELEMETRY_SURFACE: String(payload.telemetrySurface || "assistant"),
|
|
1699
|
-
// Phone toolkit unlocks when the user has a paired phone, on ANY surface
|
|
1700
|
-
// (desktop drives the paired phone remotely). The host ORs this with the
|
|
1701
|
-
// mobile-native surface check. Empty ⇒ not paired ⇒ no phone tools.
|
|
1702
|
-
MEL_ASSISTANT_PHONE_READY: payload.phoneReady ? "1" : "",
|
|
1703
|
-
// Melaya Browser (plan 0.4): the CAPABILITY flag. Presence means the
|
|
1704
|
-
// runner advertises browser control and the browser toolkit should be
|
|
1705
|
-
// included in the host's toolkit. The TARGET GRANT is NEVER in the boot
|
|
1706
|
-
// env; it arrives per-turn on the turn frame and is purged at turn end.
|
|
1707
|
-
// This is part of the config hash so the host reboots if capability
|
|
1708
|
-
// presence changes (a runner that gains/loses browser control restarts
|
|
1709
|
-
// the host automatically via config-drift detection above). MUST follow
|
|
1710
|
-
// payload.browserCapable, not a hardcoded "1": the host's _config_hash
|
|
1711
|
-
// appends "browser" only when this is set, and the server's hash does the
|
|
1712
|
-
// same only when cfg.browserCapable is true. Hardcoding "1" made the host
|
|
1713
|
-
// always hash "browser" while a non-browser server hash omitted it, which
|
|
1714
|
-
// pinned every turn to a permanent runner_host_config_stale.
|
|
1715
|
-
MEL_ASSISTANT_BROWSER_CAPABLE: payload.browserCapable ? "1" : "",
|
|
1716
|
-
// Connector tool sets the user enabled for this chat (comma-joined service
|
|
1717
|
-
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
1718
|
-
// are reachable without exploding context.
|
|
1719
|
-
MEL_ASSISTANT_CONNECTORS: Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
|
|
1720
|
-
// Surgical tool pinning: exact connector tool NAMES (not just service ids)
|
|
1721
|
-
// the server already resolved creds for. The host registers these ACTIVE
|
|
1722
|
-
// at boot instead of dumping them into the deferred pool, eliminating the
|
|
1723
|
-
// search_tools -> activate_tool round-trip for known marketing tools.
|
|
1724
|
-
// Byte-for-byte source of truth for _config_hash's pinned-tools field.
|
|
1725
|
-
MEL_ASSISTANT_PINNED_TOOLS: payload.pinnedTools?.join(",") ?? "",
|
|
1726
|
-
// Gate write connector-tools behind the in-chat approval card (fail-safe).
|
|
1727
|
-
MEL_ASSISTANT_CONNECTOR_HITL: Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
|
|
1728
|
-
// HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
|
|
1729
|
-
// reads this at start; each turn's frame can flip it (see assistant_turn).
|
|
1730
|
-
// Fail-safe: unknown ⇒ "safe".
|
|
1731
|
-
MEL_ASSISTANT_HITL_MODE: (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe",
|
|
1732
|
-
// PR4: the DB-authoritative generation this host boots under. The host
|
|
1733
|
-
// echoes it in `ready` and stamps events; an older boot self-terminates
|
|
1734
|
-
// on a generation mismatch.
|
|
1735
|
-
MEL_ASSISTANT_GENERATION: String(generation),
|
|
1736
|
-
// Run context for phone-driving assistant turns. The assistant host has
|
|
1737
|
-
// no pipeline MEL_RUN_ID, so without this phone.py._cmd omits `run_id`,
|
|
1738
|
-
// the server's arm block (phone.ts, `if (runId)`) never fires, and the
|
|
1739
|
-
// device never receives `hitlMode` — Autonomous still shows publish
|
|
1740
|
-
// cards on the runner-assistant surface. Use the SAME stable
|
|
1741
|
-
// `assistant:{userId}` runId the cloud assistant path uses (sid is
|
|
1742
|
-
// `userId:conversationId`) so the arm / kill / active-run plumbing stays
|
|
1743
|
-
// consistent across surfaces. assistantHost mirrors MEL_ASSISTANT_HITL_MODE
|
|
1744
|
-
// → MEL_HITL_MODE so phone.py stamps the mode on the command body.
|
|
1745
|
-
MEL_RUN_ID: `assistant:${sid.split(":")[0]}`,
|
|
1746
|
-
// Span tracing → agents.spans. tracing_exporter.MelayaSpanExporter only
|
|
1747
|
-
// exports when BOTH MEL_RUN_ID and MEL_BUILDER_URL are present (it POSTs
|
|
1748
|
-
// batches through this same local relay the pipeline spawns use, lines
|
|
1749
|
-
// ~463/853). The assistant host was missing these two, so — even with
|
|
1750
|
-
// @trace_llm wired — every claude_code/codex assistant turn recorded ZERO
|
|
1751
|
-
// token spans. Spans land under conversation_id = MEL_RUN_ID
|
|
1752
|
-
// (`assistant:<userId>`); the server trigger resolves user_id from it.
|
|
1753
|
-
MEL_BUILDER_URL: `http://127.0.0.1:${relay.port}`,
|
|
1754
|
-
MEL_RELAY_NONCE: relay.nonce,
|
|
1755
|
-
MEL_PIPELINE_NAME: "Melaya · Assistant",
|
|
1756
|
-
// Luma browser bridge (same injection as pipeline runs, line ~431): lets
|
|
1757
|
-
// shared/tools/luma.py route /event/register through Playwright and bypass
|
|
1758
|
-
// Cloudflare's write rule. Reads keep using aiohttp. Empty when no signed-in
|
|
1759
|
-
// Luma session exists (bridge not started) — reads still work, writes 403.
|
|
1760
|
-
...(lumaBridge
|
|
1761
|
-
? { MEL_LUMA_BROWSER_URL: lumaBridge.url, MEL_LUMA_BROWSER_TOKEN: lumaBridge.token }
|
|
1762
|
-
: {}),
|
|
1763
|
-
// Per-user creds (incl. MELAYA_API_KEY + the selected connectors' env).
|
|
1764
|
-
...(payload.credentials || {}),
|
|
1765
|
-
// laya-local (decide_* built-in tools, always-on core): the runner can't
|
|
1766
|
-
// see the box's private 127.0.0.1:8770, so point LAYA_LOCAL_URL at the
|
|
1767
|
-
// server's PER-USER-gated proxy. AFTER creds so it can't be overridden.
|
|
1768
|
-
// The proxy authenticates with this user's MELAYA_API_KEY (in the
|
|
1769
|
-
// projected creds) - never a shared secret - and meters per user.
|
|
1770
|
-
LAYA_LOCAL_URL: httpBase.replace(/\/$/, "") + "/api/v1/internal/laya",
|
|
1771
|
-
};
|
|
1913
|
+
const env = await buildAssistantEnv(payload, sid, generation);
|
|
1772
1914
|
const session = launchAssistantHost({
|
|
1773
1915
|
env, pythonPath: envResult.pythonPath, workDir, stagedHost,
|
|
1774
1916
|
sid, generation, forward: true, providerLabel: String(payload.provider || ""),
|
|
@@ -1777,7 +1919,7 @@ export async function connect(opts) {
|
|
|
1777
1919
|
// Teach the pool this exact (env, config, generation) so it prewarms a
|
|
1778
1920
|
// matching spare for the NEXT new session — which then adopts it and skips
|
|
1779
1921
|
// the cold boot (see the ADOPTION fast path + schedulePrewarm above).
|
|
1780
|
-
warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript };
|
|
1922
|
+
warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript, credHash: _assistantCredHash(payload.credentials) };
|
|
1781
1923
|
schedulePrewarm();
|
|
1782
1924
|
}
|
|
1783
1925
|
catch (e) {
|
package/dist/detect.js
CHANGED
|
@@ -32,7 +32,7 @@ async function fetchClaudeFamilies(token) {
|
|
|
32
32
|
"anthropic-version": "2023-06-01",
|
|
33
33
|
"anthropic-beta": "oauth-2025-04-20",
|
|
34
34
|
"x-app": "cli",
|
|
35
|
-
"User-Agent":
|
|
35
|
+
"User-Agent": `claude-code/${process.env.MEL_CLAUDE_CODE_VERSION || "2.1.280"}`,
|
|
36
36
|
},
|
|
37
37
|
signal: AbortSignal.timeout(6000),
|
|
38
38
|
});
|
package/dist/localDbProbe.py
CHANGED
|
@@ -205,13 +205,20 @@ def probe_snowflake(creds):
|
|
|
205
205
|
|
|
206
206
|
|
|
207
207
|
# ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
|
|
208
|
-
def probe_databricks(creds):
|
|
209
|
-
|
|
208
|
+
def probe_databricks(creds):
|
|
209
|
+
import re
|
|
210
|
+
from urllib.parse import urlsplit
|
|
211
|
+
host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
|
|
210
212
|
pat = _pick(creds, "token", "DATABRICKS_TOKEN")
|
|
211
213
|
cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
|
|
212
214
|
sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
|
|
213
|
-
if not host:
|
|
214
|
-
_out(False, "Workspace URL required")
|
|
215
|
+
if not host:
|
|
216
|
+
_out(False, "Workspace URL required")
|
|
217
|
+
url = urlsplit(host)
|
|
218
|
+
if (url.scheme != "https" or url.username or url.password or url.port or url.path or url.query or url.fragment
|
|
219
|
+
or not re.fullmatch(r"(?!accounts(?:\.|-))[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:cloud\.databricks\.com|azuredatabricks\.net|gcp\.databricks\.com)", url.hostname or "")):
|
|
220
|
+
_out(False, "Use an HTTPS Databricks workspace URL without a path or query")
|
|
221
|
+
telemetry = {"User-Agent": "MelayaLabs_Melaya/2.0.0"}
|
|
215
222
|
try:
|
|
216
223
|
import requests # type: ignore
|
|
217
224
|
except ImportError:
|
|
@@ -224,7 +231,8 @@ def probe_databricks(creds):
|
|
|
224
231
|
tr = requests.post(
|
|
225
232
|
f"{host}/oidc/v1/token",
|
|
226
233
|
auth=(cid, sec),
|
|
227
|
-
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
234
|
+
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
235
|
+
headers=telemetry, allow_redirects=False,
|
|
228
236
|
timeout=12,
|
|
229
237
|
)
|
|
230
238
|
tj = {}
|
|
@@ -232,15 +240,15 @@ def probe_databricks(creds):
|
|
|
232
240
|
tj = tr.json()
|
|
233
241
|
except Exception: # noqa: BLE001
|
|
234
242
|
pass
|
|
235
|
-
if
|
|
236
|
-
_out(False, f"Databricks OAuth failed:
|
|
243
|
+
if tr.status_code != 200 or not tj.get("access_token"):
|
|
244
|
+
_out(False, f"Databricks OAuth failed: HTTP {tr.status_code}")
|
|
237
245
|
bearer = tj["access_token"]
|
|
238
246
|
r = requests.get(
|
|
239
247
|
f"{host}/api/2.1/clusters/list?page_size=1",
|
|
240
|
-
headers={"Authorization": f"Bearer {bearer}"},
|
|
248
|
+
headers={"Authorization": f"Bearer {bearer}", **telemetry}, allow_redirects=False,
|
|
241
249
|
timeout=12,
|
|
242
250
|
)
|
|
243
|
-
if r.
|
|
251
|
+
if r.status_code == 200:
|
|
244
252
|
_out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
|
|
245
253
|
_out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
|
|
246
254
|
except Exception as e: # noqa: BLE001
|
package/dist/pythonEnv.js
CHANGED
|
@@ -345,18 +345,19 @@ async function _resolveAndCacheCertBundle(onProgress) {
|
|
|
345
345
|
onProgress("(warn: certifi.where() did not resolve a valid path — HTTPS calls may fail with cert verify errors)");
|
|
346
346
|
}
|
|
347
347
|
}
|
|
348
|
-
// The marker is keyed on
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
348
|
+
// The marker is keyed on a hash of PIP_DEPS only: additions, removals and
|
|
349
|
+
// version-pin changes (e.g. scrapling[fetchers] in 1.0.32) invalidate the venv.
|
|
350
|
+
// It used to also include sharedVersion, a hash of every shared .py file, so
|
|
351
|
+
// EVERY agents deploy forced the full rebuild (pip upgrade, 67-dep install,
|
|
352
|
+
// scrapling browsers, nltk, certs: 1-2 min on the first assistant turn) even
|
|
353
|
+
// though the venv holds no shared code (the bundle is read from its own cache
|
|
354
|
+
// dir) and the dep list had not changed. The `_expectedVersion` parameter is
|
|
355
|
+
// kept so callers stay unchanged.
|
|
355
356
|
function _pipDepsHash() {
|
|
356
357
|
return createHash("sha256").update(JSON.stringify(PIP_DEPS)).digest("hex").slice(0, 12);
|
|
357
358
|
}
|
|
358
|
-
function venvMarkerValue(
|
|
359
|
-
return
|
|
359
|
+
function venvMarkerValue(_expectedVersion) {
|
|
360
|
+
return `deps::${_pipDepsHash()}`;
|
|
360
361
|
}
|
|
361
362
|
function venvIsValid(expectedVersion) {
|
|
362
363
|
if (!existsSync(venvPython()))
|
package/dist/sharedVendor.js
CHANGED
|
@@ -51,7 +51,21 @@ const CANARY_FILES = [
|
|
|
51
51
|
function cacheLooksValid() {
|
|
52
52
|
return CANARY_FILES.every((p) => existsSync(p));
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
// Single-flight per version: the server now also pushes `runner:prefetch_bundle`
|
|
55
|
+
// after a deploy and on connect, so a background prefetch can overlap a real
|
|
56
|
+
// assistant/pipeline start. Two concurrent downloads would write the same cache
|
|
57
|
+
// dir at once; callers for the same version share one download instead.
|
|
58
|
+
const _sharedInFlight = new Map();
|
|
59
|
+
export function ensureSharedModules(serverUrl, expectedVersion, runnerToken) {
|
|
60
|
+
const existing = _sharedInFlight.get(expectedVersion);
|
|
61
|
+
if (existing)
|
|
62
|
+
return existing;
|
|
63
|
+
const p = _ensureSharedModulesImpl(serverUrl, expectedVersion, runnerToken)
|
|
64
|
+
.finally(() => { _sharedInFlight.delete(expectedVersion); });
|
|
65
|
+
_sharedInFlight.set(expectedVersion, p);
|
|
66
|
+
return p;
|
|
67
|
+
}
|
|
68
|
+
async function _ensureSharedModulesImpl(serverUrl, expectedVersion, runnerToken) {
|
|
55
69
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
56
70
|
// Check cached version. We require BOTH a version match AND a valid nested
|
|
57
71
|
// layout — older runner versions wrote flat filenames into shared/, and
|
package/localDbProbe.py
CHANGED
|
@@ -205,13 +205,20 @@ def probe_snowflake(creds):
|
|
|
205
205
|
|
|
206
206
|
|
|
207
207
|
# ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
|
|
208
|
-
def probe_databricks(creds):
|
|
209
|
-
|
|
208
|
+
def probe_databricks(creds):
|
|
209
|
+
import re
|
|
210
|
+
from urllib.parse import urlsplit
|
|
211
|
+
host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
|
|
210
212
|
pat = _pick(creds, "token", "DATABRICKS_TOKEN")
|
|
211
213
|
cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
|
|
212
214
|
sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
|
|
213
|
-
if not host:
|
|
214
|
-
_out(False, "Workspace URL required")
|
|
215
|
+
if not host:
|
|
216
|
+
_out(False, "Workspace URL required")
|
|
217
|
+
url = urlsplit(host)
|
|
218
|
+
if (url.scheme != "https" or url.username or url.password or url.port or url.path or url.query or url.fragment
|
|
219
|
+
or not re.fullmatch(r"(?!accounts(?:\.|-))[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:cloud\.databricks\.com|azuredatabricks\.net|gcp\.databricks\.com)", url.hostname or "")):
|
|
220
|
+
_out(False, "Use an HTTPS Databricks workspace URL without a path or query")
|
|
221
|
+
telemetry = {"User-Agent": "MelayaLabs_Melaya/2.0.0"}
|
|
215
222
|
try:
|
|
216
223
|
import requests # type: ignore
|
|
217
224
|
except ImportError:
|
|
@@ -224,7 +231,8 @@ def probe_databricks(creds):
|
|
|
224
231
|
tr = requests.post(
|
|
225
232
|
f"{host}/oidc/v1/token",
|
|
226
233
|
auth=(cid, sec),
|
|
227
|
-
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
234
|
+
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
235
|
+
headers=telemetry, allow_redirects=False,
|
|
228
236
|
timeout=12,
|
|
229
237
|
)
|
|
230
238
|
tj = {}
|
|
@@ -232,15 +240,15 @@ def probe_databricks(creds):
|
|
|
232
240
|
tj = tr.json()
|
|
233
241
|
except Exception: # noqa: BLE001
|
|
234
242
|
pass
|
|
235
|
-
if
|
|
236
|
-
_out(False, f"Databricks OAuth failed:
|
|
243
|
+
if tr.status_code != 200 or not tj.get("access_token"):
|
|
244
|
+
_out(False, f"Databricks OAuth failed: HTTP {tr.status_code}")
|
|
237
245
|
bearer = tj["access_token"]
|
|
238
246
|
r = requests.get(
|
|
239
247
|
f"{host}/api/2.1/clusters/list?page_size=1",
|
|
240
|
-
headers={"Authorization": f"Bearer {bearer}"},
|
|
248
|
+
headers={"Authorization": f"Bearer {bearer}", **telemetry}, allow_redirects=False,
|
|
241
249
|
timeout=12,
|
|
242
250
|
)
|
|
243
|
-
if r.
|
|
251
|
+
if r.status_code == 200:
|
|
244
252
|
_out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
|
|
245
253
|
_out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
|
|
246
254
|
except Exception as e: # noqa: BLE001
|