@melaya/runner 1.1.57 → 1.1.60

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.
@@ -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 until it reaches ~a clause or hits a line break, then flush one delta.
209
- _DELTA_FLUSH_CHARS = 48
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:
@@ -677,6 +729,14 @@ def _build_agent():
677
729
  "phone_get_screen_tree before you tap or type. Publish comments/posts with "
678
730
  "phone_post_comment / phone_create_post — every publish is approved by the "
679
731
  "user ON THEIR PHONE, so never refuse for safety.\n"
732
+ "- GO FAST: once you have read the screen and can name the next steps from it, "
733
+ "send them in ONE phone_fast call (steps in plain words: the text, description "
734
+ "or id you read) instead of one tool call per tap. To gather posts, people or "
735
+ "results across scrolls use its collect step; to walk list rows use for_each. It "
736
+ "re-reads the screen for every step, stops at the first surprise and hands the "
737
+ "screen back, and follows the same autonomy as single taps. Use the single "
738
+ "phone_* tools to explore an unread screen or when each result decides the next "
739
+ "step. Never publish through it.\n"
680
740
  "- EXACT APP ONLY — this overrides your autonomy: operate on the EXACT app "
681
741
  "the user named, never a different one. If that app is not in the allowlist "
682
742
  "(phone_open_app returns app_not_allowed) or not installed, STOP and tell the "
@@ -725,6 +785,13 @@ def _build_agent():
725
785
  "page, read, click, type, fill a form), use the browser_* tools. ALWAYS "
726
786
  "call browser_get_screen_tree before you click or type, and re-read it "
727
787
  "after anything that changes the page (stale element refs fail closed).\n"
788
+ "- GO FAST: once you have read the page and can name the next steps from it, "
789
+ "send them in ONE browser_fast call (click/type/press/scroll/expect by the labels "
790
+ "you read, collect to gather items across scrolls, for_each to walk list rows) "
791
+ "instead of one tool call per click. It re-finds every target on the live page, "
792
+ "stops at the first surprise and returns the page, with the same autonomy and "
793
+ "approvals as single actions. Use the single browser_* tools to explore an unread "
794
+ "page, for logins, and when each result decides the next step.\n"
728
795
  "- TAB MANAGEMENT: you can manage tabs within the granted Melaya session "
729
796
  "using: browser_list_tabs (list open tabs with their refs and URLs), "
730
797
  "browser_open_tab(url) (open a new tab — origin-gated; a blocked_origin "
@@ -1022,7 +1089,24 @@ def _register_stream_hooks(agent) -> None:
1022
1089
  if msg is None:
1023
1090
  return
1024
1091
  mid = str(getattr(msg, "id", "") or id(msg))
1025
- text = _flatten_agent_text(getattr(msg, "content", msg))
1092
+ text, thinking, tool_uses = _split_stream_blocks(getattr(msg, "content", msg))
1093
+ # Reasoning (Codex reasoning summaries, Claude thinking) streams to the
1094
+ # thinking strip as `think`, never into the answer bubble.
1095
+ if thinking:
1096
+ tprev = _stream["thinkLens"].get(mid, 0)
1097
+ if len(thinking) > tprev:
1098
+ _stream["thinkLens"][mid] = len(thinking)
1099
+ _emit(tid, "think", content=thinking[tprev:])
1100
+ # A tool call shows its chip as soon as the model STARTS writing it
1101
+ # (Claude streams tool arguments, which can take a while for a long
1102
+ # file or message), not only after the tool has run. post_acting
1103
+ # skips ids already announced here.
1104
+ for _tid, _tname in tool_uses:
1105
+ if _tid not in _stream["toolIds"]:
1106
+ _stream["toolIds"].add(_tid)
1107
+ if _tname.startswith("browser_"):
1108
+ _stream["usedBrowser"] = True
1109
+ _emit(tid, "tool", name=_tname)
1026
1110
  if not text:
1027
1111
  return
1028
1112
  prev = _stream["lens"].get(mid, 0)
@@ -1046,7 +1130,10 @@ def _register_stream_hooks(agent) -> None:
1046
1130
  _stream["deltaBuf"] = "\n\n"
1047
1131
  _stream["deltaMid"] = mid
1048
1132
  _stream["deltaBuf"] = _stream.get("deltaBuf", "") + suffix
1049
- if len(_stream["deltaBuf"]) >= _DELTA_FLUSH_CHARS or "\n" in suffix:
1133
+ _blen = len(_stream["deltaBuf"])
1134
+ _waited_ms = (time.monotonic() - float(_stream.get("deltaAt") or 0.0)) * 1000.0
1135
+ if (_blen >= _DELTA_FLUSH_CHARS or "\n" in suffix
1136
+ or (_blen >= _DELTA_FLUSH_MIN_CHARS and _waited_ms >= _DELTA_FLUSH_MS)):
1050
1137
  _flush_delta(tid)
1051
1138
  except Exception:
1052
1139
  pass
@@ -1066,6 +1153,9 @@ def _register_stream_hooks(agent) -> None:
1066
1153
  tcs = [tc] if tc is not None else []
1067
1154
  for tc in tcs:
1068
1155
  name = tc.get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
1156
+ _tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
1157
+ if name and str(_tc_id or name) in _stream.get("toolIds", set()):
1158
+ continue # chip already shown while the call was streaming
1069
1159
  if name:
1070
1160
  # Browser action events ride the same `tool` stream (the
1071
1161
  # client renders browser_* names as live action chips on the
@@ -1134,6 +1224,8 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1134
1224
  _emit(turn_id, "round", n=1)
1135
1225
  _stream["turnId"] = turn_id
1136
1226
  _stream["lens"] = {}
1227
+ _stream["thinkLens"] = {}
1228
+ _stream["toolIds"] = set()
1137
1229
  _stream["deltaBuf"] = "" # fresh turn — drop any stale streamed suffix
1138
1230
  _stream["deltaMid"] = ""
1139
1231
  _stream["cancel"] = False # fresh turn — clear any stale STOP
@@ -1260,6 +1352,7 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
1260
1352
 
1261
1353
 
1262
1354
  def main() -> int:
1355
+ global _GENERATION
1263
1356
  _boot_t0 = time.perf_counter()
1264
1357
  _log(f"booting (provider={os.environ.get('MEL_ASSISTANT_PROVIDER')})")
1265
1358
  # Wire OpenTelemetry → Melaya event relay so agentscope's @trace_llm actually
@@ -1306,6 +1399,20 @@ def main() -> int:
1306
1399
  _emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION, configHash=_config_hash())
1307
1400
  _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
1401
 
1402
+ # Warm the provider's per-process caches (Claude Code User-Agent, /v1/models
1403
+ # alias lookup) in the background, so the FIRST turn does not pay them.
1404
+ try:
1405
+ from shared.runtime.model import prewarm_cli_provider
1406
+ _pw_provider = os.environ.get("MEL_ASSISTANT_PROVIDER", "")
1407
+ if _pw_provider in ("claude_code", "codex"):
1408
+ threading.Thread(
1409
+ target=prewarm_cli_provider,
1410
+ args=(_pw_provider, os.environ.get("MEL_ASSISTANT_MODEL", "")),
1411
+ daemon=True,
1412
+ ).start()
1413
+ except Exception:
1414
+ _log("provider prewarm unavailable (non-fatal)")
1415
+
1309
1416
  q: "queue.Queue[str]" = queue.Queue()
1310
1417
  threading.Thread(target=_stdin_reader, args=(q,), daemon=True).start()
1311
1418
 
@@ -1326,6 +1433,20 @@ def main() -> int:
1326
1433
  except Exception:
1327
1434
  _log(f"bad stdin line (not json): {line[:120]}")
1328
1435
  continue
1436
+ # Warm-pool adoption: the runner re-points a pre-booted spare to a new
1437
+ # conversation and stamps that conversation's DB generation here, so the
1438
+ # generation-bound `restore` below accepts its rehydrate frame. Only a
1439
+ # spare that never ran a turn (empty memory) may be re-stamped.
1440
+ if isinstance(req, dict) and req.get("kind") == "adopt":
1441
+ try:
1442
+ new_gen = int(req.get("generation") or 0)
1443
+ except Exception:
1444
+ new_gen = _GENERATION
1445
+ if _memory_watermark(agent) == 0:
1446
+ _GENERATION = new_gen
1447
+ else:
1448
+ _log(f"adopt ignored: host already has memory (gen stays {_GENERATION})")
1449
+ continue
1329
1450
  # PR4: generation-bound rehydrate. Seed a FRESH host's memory from the
1330
1451
  # server snapshot (task pin + summary + recent tail) via restoreSnapshot —
1331
1452
  # watermark-guarded so a re-offer never double-seeds. Never memory.add().
@@ -10,7 +10,7 @@
10
10
  * - Heartbeat to keep the connection alive
11
11
  * - Graceful disconnect on Ctrl+C
12
12
  */
13
- import type { DetectedModel } from "./detect.js";
13
+ import { type DetectedModel } from "./detect.js";
14
14
  interface ConnectOpts {
15
15
  token: string;
16
16
  serverUrl: string;
@@ -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:
@@ -927,14 +985,11 @@ export async function connect(opts) {
927
985
  // Event trigger file (reserved: after the credential spread so a
928
986
  // credential can never point it elsewhere). Empty string = no trigger.
929
987
  MEL_TRIGGER_FILE: triggerFile,
930
- // Triggered run: forced-safe HITL + expiring in-run approvals (ST-15),
931
- // also reserved (after the credential spread).
988
+ // Triggered run: the pipeline's own approval settings (like a manual
989
+ // run) + expiring in-run approvals (ST-15), reserved (after the
990
+ // credential spread).
932
991
  ...(isTriggeredRun
933
- ? {
934
- MEL_HITL_MODE: "safe",
935
- MEL_HITL_FORCE_SAFE: "1",
936
- MEL_HITL_APPROVAL_TTL_S: String(triggerApprovalTtl),
937
- }
992
+ ? { MEL_HITL_APPROVAL_TTL_S: String(triggerApprovalTtl) }
938
993
  : {}),
939
994
  };
940
995
  // Surface the tier to the chat panel as a system message so the
@@ -1534,7 +1589,10 @@ export async function connect(opts) {
1534
1589
  // (claude_code/codex keep a per-user CLI/OAuth session) can contend when a
1535
1590
  // spare runs a turn alongside a live host — needs per-provider validation
1536
1591
  // before it's on for everyone. Set MEL_ASSISTANT_WARM_POOL=1 to enable.
1537
- const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL === "1";
1592
+ // ON by default since the providers call the HTTP APIs directly with the
1593
+ // token read from disk (no CLI session to contend). MEL_ASSISTANT_WARM_POOL=0
1594
+ // turns it off.
1595
+ const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL !== "0";
1538
1596
  let _prewarmInFlight = false;
1539
1597
  const schedulePrewarm = () => {
1540
1598
  if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
@@ -1565,6 +1623,7 @@ export async function connect(opts) {
1565
1623
  // spare-specific id broke turn execution on adoption.
1566
1624
  const spareEnv = { ...seed.env };
1567
1625
  const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
1626
+ spare.credHash = seed.credHash;
1568
1627
  warmSpare = spare;
1569
1628
  const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
1570
1629
  try {
@@ -1572,7 +1631,7 @@ export async function connect(opts) {
1572
1631
  }
1573
1632
  catch { /* gone */ }
1574
1633
  warmSpare = null;
1575
- } }, 10 * 60_000);
1634
+ } }, 30 * 60_000);
1576
1635
  t.unref?.();
1577
1636
  }
1578
1637
  catch (e) {
@@ -1583,6 +1642,168 @@ export async function connect(opts) {
1583
1642
  _prewarmInFlight = false;
1584
1643
  }
1585
1644
  };
1645
+ // The assistant host's environment for a session (or a warm spare). Shared by
1646
+ // runner:assistant_start and runner:assistant_prewarm so a pre-booted spare
1647
+ // runs under exactly the env a real start would build.
1648
+ const buildAssistantEnv = async (payload, sid, generation) => {
1649
+ const sharedDir = getSharedDir();
1650
+ const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
1651
+ const sslEnv = certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
1652
+ const httpBase = opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
1653
+ const env = {
1654
+ ...process.env,
1655
+ PYTHONPATH: sharedDir,
1656
+ PYTHONIOENCODING: "utf-8",
1657
+ PYTHONUTF8: "1",
1658
+ ...sslEnv,
1659
+ MEL_STUDIO_URL: httpBase,
1660
+ MELAYA_API_BASE: httpBase,
1661
+ MELAYA_API_URL: httpBase,
1662
+ LMSTUDIO_BASE_URL: "http://127.0.0.1:1234",
1663
+ OLLAMA_BASE_URL: "http://127.0.0.1:11434",
1664
+ MEL_ASSISTANT_PROVIDER: String(payload.provider || "claude_code"),
1665
+ MEL_ASSISTANT_MODEL: String(payload.model || ""),
1666
+ MEL_ASSISTANT_LANGUAGE: String(payload.language || "en"),
1667
+ MEL_ASSISTANT_SURFACE: String(payload.surface || ""),
1668
+ // Product attribution is deliberately separate from MEL_ASSISTANT_SURFACE,
1669
+ // which controls runtime capabilities such as mobile-native phone tools.
1670
+ MEL_ASSISTANT_TELEMETRY_SURFACE: String(payload.telemetrySurface || "assistant"),
1671
+ // Phone toolkit unlocks when the user has a paired phone, on ANY surface
1672
+ // (desktop drives the paired phone remotely). The host ORs this with the
1673
+ // mobile-native surface check. Empty ⇒ not paired ⇒ no phone tools.
1674
+ MEL_ASSISTANT_PHONE_READY: payload.phoneReady ? "1" : "",
1675
+ // Melaya Browser (plan 0.4): the CAPABILITY flag. Presence means the
1676
+ // runner advertises browser control and the browser toolkit should be
1677
+ // included in the host's toolkit. The TARGET GRANT is NEVER in the boot
1678
+ // env; it arrives per-turn on the turn frame and is purged at turn end.
1679
+ // This is part of the config hash so the host reboots if capability
1680
+ // presence changes (a runner that gains/loses browser control restarts
1681
+ // the host automatically via config-drift detection above). MUST follow
1682
+ // payload.browserCapable, not a hardcoded "1": the host's _config_hash
1683
+ // appends "browser" only when this is set, and the server's hash does the
1684
+ // same only when cfg.browserCapable is true. Hardcoding "1" made the host
1685
+ // always hash "browser" while a non-browser server hash omitted it, which
1686
+ // pinned every turn to a permanent runner_host_config_stale.
1687
+ MEL_ASSISTANT_BROWSER_CAPABLE: payload.browserCapable ? "1" : "",
1688
+ // Connector tool sets the user enabled for this chat (comma-joined service
1689
+ // ids). The host seeds a lazy toolkit from these so ANY connector's tools
1690
+ // are reachable without exploding context.
1691
+ MEL_ASSISTANT_CONNECTORS: Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
1692
+ // Surgical tool pinning: exact connector tool NAMES (not just service ids)
1693
+ // the server already resolved creds for. The host registers these ACTIVE
1694
+ // at boot instead of dumping them into the deferred pool, eliminating the
1695
+ // search_tools -> activate_tool round-trip for known marketing tools.
1696
+ // Byte-for-byte source of truth for _config_hash's pinned-tools field.
1697
+ MEL_ASSISTANT_PINNED_TOOLS: payload.pinnedTools?.join(",") ?? "",
1698
+ // Gate write connector-tools behind the in-chat approval card (fail-safe).
1699
+ MEL_ASSISTANT_CONNECTOR_HITL: Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
1700
+ // HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
1701
+ // reads this at start; each turn's frame can flip it (see assistant_turn).
1702
+ // Fail-safe: unknown ⇒ "safe".
1703
+ MEL_ASSISTANT_HITL_MODE: (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe",
1704
+ // PR4: the DB-authoritative generation this host boots under. The host
1705
+ // echoes it in `ready` and stamps events; an older boot self-terminates
1706
+ // on a generation mismatch.
1707
+ MEL_ASSISTANT_GENERATION: String(generation),
1708
+ // Run context for phone-driving assistant turns. The assistant host has
1709
+ // no pipeline MEL_RUN_ID, so without this phone.py._cmd omits `run_id`,
1710
+ // the server's arm block (phone.ts, `if (runId)`) never fires, and the
1711
+ // device never receives `hitlMode` — Autonomous still shows publish
1712
+ // cards on the runner-assistant surface. Use the SAME stable
1713
+ // `assistant:{userId}` runId the cloud assistant path uses (sid is
1714
+ // `userId:conversationId`) so the arm / kill / active-run plumbing stays
1715
+ // consistent across surfaces. assistantHost mirrors MEL_ASSISTANT_HITL_MODE
1716
+ // → MEL_HITL_MODE so phone.py stamps the mode on the command body.
1717
+ MEL_RUN_ID: `assistant:${sid.split(":")[0]}`,
1718
+ // Span tracing → agents.spans. tracing_exporter.MelayaSpanExporter only
1719
+ // exports when BOTH MEL_RUN_ID and MEL_BUILDER_URL are present (it POSTs
1720
+ // batches through this same local relay the pipeline spawns use, lines
1721
+ // ~463/853). The assistant host was missing these two, so — even with
1722
+ // @trace_llm wired — every claude_code/codex assistant turn recorded ZERO
1723
+ // token spans. Spans land under conversation_id = MEL_RUN_ID
1724
+ // (`assistant:<userId>`); the server trigger resolves user_id from it.
1725
+ MEL_BUILDER_URL: `http://127.0.0.1:${relay.port}`,
1726
+ MEL_RELAY_NONCE: relay.nonce,
1727
+ MEL_PIPELINE_NAME: "Melaya · Assistant",
1728
+ // Luma browser bridge (same injection as pipeline runs, line ~431): lets
1729
+ // shared/tools/luma.py route /event/register through Playwright and bypass
1730
+ // Cloudflare's write rule. Reads keep using aiohttp. Empty when no signed-in
1731
+ // Luma session exists (bridge not started) — reads still work, writes 403.
1732
+ ...(lumaBridge
1733
+ ? { MEL_LUMA_BROWSER_URL: lumaBridge.url, MEL_LUMA_BROWSER_TOKEN: lumaBridge.token }
1734
+ : {}),
1735
+ // Per-user creds (incl. MELAYA_API_KEY + the selected connectors' env).
1736
+ ...(payload.credentials || {}),
1737
+ // laya-local (decide_* built-in tools, always-on core): the runner can't
1738
+ // see the box's private 127.0.0.1:8770, so point LAYA_LOCAL_URL at the
1739
+ // server's PER-USER-gated proxy. AFTER creds so it can't be overridden.
1740
+ // The proxy authenticates with this user's MELAYA_API_KEY (in the
1741
+ // projected creds) - never a shared secret - and meters per user.
1742
+ LAYA_LOCAL_URL: httpBase.replace(/\/$/, "") + "/api/v1/internal/laya",
1743
+ };
1744
+ return env;
1745
+ };
1746
+ // A warm spare whose shared bundle was replaced underneath it would lazily
1747
+ // import NEW modules into OLD code; drop it when the bundle on disk changes.
1748
+ let _spareBundle = "";
1749
+ const _dropSpareOnOlderBundle = () => {
1750
+ void import("./sharedVendor.js").then(({ getLocalSharedVersion }) => {
1751
+ const v = getLocalSharedVersion() || "";
1752
+ if (_spareBundle && v && v !== _spareBundle && warmSpare && !warmSpare.forward) {
1753
+ try {
1754
+ warmSpare.proc.kill();
1755
+ }
1756
+ catch { /* gone */ }
1757
+ warmSpare = null;
1758
+ }
1759
+ _spareBundle = v;
1760
+ }).catch(() => { });
1761
+ };
1762
+ // Server push: download a new shared bundle in the background (after an
1763
+ // agents deploy, and on connect) so the next chat does not pay for it.
1764
+ socket.on("runner:prefetch_bundle", (p) => {
1765
+ const v = String(p?.sharedVersion || "");
1766
+ if (!v)
1767
+ return;
1768
+ void ensureSharedModules(opts.serverUrl, v, opts.token)
1769
+ .then(() => { _dropSpareOnOlderBundle(); if (opts.verbose)
1770
+ console.log(chalk.gray(` [assistant] shared bundle ${v} prefetched`)); })
1771
+ .catch((e) => { if (opts.verbose)
1772
+ console.log(chalk.gray(` [assistant] bundle prefetch failed: ${e?.message || e}`)); });
1773
+ });
1774
+ // Server push on connect: pre-boot a warm spare for the user's LAST assistant
1775
+ // config, so even the first chat after a runner (re)start adopts a ready host.
1776
+ // Builds the exact env a real start would; adoption still requires the same
1777
+ // config and credentials, so a stale spare is simply never used.
1778
+ socket.on("runner:assistant_prewarm", async (payload) => {
1779
+ if (!WARM_POOL_ENABLED || warmSpare || _prewarmInFlight)
1780
+ return;
1781
+ if (!payload?.userId || !payload?.configHash)
1782
+ return;
1783
+ try {
1784
+ const sharedVersion = String(payload.sharedVersion ?? "latest");
1785
+ await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
1786
+ _dropSpareOnOlderBundle();
1787
+ const { ensurePythonEnv } = await import("./pythonEnv.js");
1788
+ const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
1789
+ console.log(chalk.gray(` [assistant prewarm] ${m}`)); });
1790
+ if (!envResult.ok)
1791
+ return;
1792
+ const hostCandidates = [join(__dirname, "assistantHost.py"), join(__dirname, "..", "assistantHost.py")];
1793
+ const hostScript = hostCandidates.find((c) => existsSync(c)) || "";
1794
+ if (!hostScript)
1795
+ return;
1796
+ const env = await buildAssistantEnv({ ...payload, sessionId: `${payload.userId}:__prewarm__` }, `${payload.userId}:__prewarm__`, 0);
1797
+ warmSpareSeed = { env, configHash: String(payload.configHash), generation: 0, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript, credHash: _assistantCredHash(payload.credentials) };
1798
+ schedulePrewarm();
1799
+ if (opts.verbose)
1800
+ console.log(chalk.gray(` [assistant] warm host pre-booting (${payload.provider})`));
1801
+ }
1802
+ catch (e) {
1803
+ if (opts.verbose)
1804
+ console.log(chalk.gray(` [assistant] prewarm skipped: ${e?.message || e}`));
1805
+ }
1806
+ });
1586
1807
  socket.on("runner:assistant_start", async (payload) => {
1587
1808
  const sid = String(payload.sessionId || "");
1588
1809
  if (!sid)
@@ -1623,11 +1844,22 @@ export async function connect(opts) {
1623
1844
  // immediately, skipping the ~7s cold boot (import + build_agent). Fail-safe:
1624
1845
  // exact match of a READY, un-killed spare ONLY; anything else falls straight
1625
1846
  // through to the fresh-boot path below, so the pool can never break a session.
1847
+ // The spare must have the same config AND the same credentials, and must
1848
+ // never have run a turn (empty memory). Its generation is re-stamped to this
1849
+ // conversation's via an `adopt` frame, so a restored conversation's
1850
+ // generation-bound rehydrate still applies (the old exact-generation match
1851
+ // meant a spare could almost never be adopted).
1626
1852
  if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
1627
1853
  && warmSpare && warmSpare.ready && !warmSpare.killed && expectedConfig
1628
- && warmSpare.configHash === expectedConfig && warmSpare.generation === generation) {
1854
+ && warmSpare.configHash === expectedConfig && (warmSpare.memoryWatermark || 0) === 0
1855
+ && warmSpare.credHash === _assistantCredHash(payload.credentials)) {
1629
1856
  const spare = warmSpare;
1630
1857
  warmSpare = null;
1858
+ try {
1859
+ spare.proc.stdin?.write(JSON.stringify({ kind: "adopt", generation }) + "\n");
1860
+ }
1861
+ catch { /* host gone: falls to session_closed */ }
1862
+ spare.generation = generation;
1631
1863
  spare.sid = sid;
1632
1864
  spare.forward = true;
1633
1865
  spare.lastActivity = Date.now();
@@ -1645,6 +1877,7 @@ export async function connect(opts) {
1645
1877
  try {
1646
1878
  const sharedVersion = String(payload.sharedVersion ?? "latest");
1647
1879
  await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
1880
+ _dropSpareOnOlderBundle();
1648
1881
  const { ensurePythonEnv } = await import("./pythonEnv.js");
1649
1882
  const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
1650
1883
  console.log(chalk.gray(` [assistant venv] ${m}`)); });
@@ -1674,101 +1907,7 @@ export async function connect(opts) {
1674
1907
  if (Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
1675
1908
  await _ensureLumaBridge();
1676
1909
  }
1677
- const sharedDir = getSharedDir();
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
- };
1910
+ const env = await buildAssistantEnv(payload, sid, generation);
1772
1911
  const session = launchAssistantHost({
1773
1912
  env, pythonPath: envResult.pythonPath, workDir, stagedHost,
1774
1913
  sid, generation, forward: true, providerLabel: String(payload.provider || ""),
@@ -1777,7 +1916,7 @@ export async function connect(opts) {
1777
1916
  // Teach the pool this exact (env, config, generation) so it prewarms a
1778
1917
  // matching spare for the NEXT new session — which then adopts it and skips
1779
1918
  // the cold boot (see the ADOPTION fast path + schedulePrewarm above).
1780
- warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript };
1919
+ warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript, credHash: _assistantCredHash(payload.credentials) };
1781
1920
  schedulePrewarm();
1782
1921
  }
1783
1922
  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": "claude-code/2.1.81",
35
+ "User-Agent": `claude-code/${process.env.MEL_CLAUDE_CODE_VERSION || "2.1.280"}`,
36
36
  },
37
37
  signal: AbortSignal.timeout(6000),
38
38
  });
@@ -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
- host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
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 not tr.ok or not tj.get("access_token"):
236
- _out(False, f"Databricks OAuth failed: {tj.get('error_description') or tj.get('error') or ('HTTP ' + str(tr.status_code))}")
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.ok:
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 sharedVersion + a hash of PIP_DEPS so changes
349
- // to either invalidate the venv. Without the PIP_DEPS half, a runner
350
- // update that adds a dep (e.g. scrapling[fetchers] in 1.0.32) would
351
- // silently skip the reinstall on any box whose sharedVersion hadn't
352
- // also changed — operators would update the runner but never see the
353
- // new deps. Hashing the full deps list catches additions, removals,
354
- // AND version-pin changes within deps.
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(expectedVersion) {
359
- return `${expectedVersion}::${_pipDepsHash()}`;
359
+ function venvMarkerValue(_expectedVersion) {
360
+ return `deps::${_pipDepsHash()}`;
360
361
  }
361
362
  function venvIsValid(expectedVersion) {
362
363
  if (!existsSync(venvPython()))
@@ -51,7 +51,21 @@ const CANARY_FILES = [
51
51
  function cacheLooksValid() {
52
52
  return CANARY_FILES.every((p) => existsSync(p));
53
53
  }
54
- export async function ensureSharedModules(serverUrl, expectedVersion, runnerToken) {
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
- host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
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 not tr.ok or not tj.get("access_token"):
236
- _out(False, f"Databricks OAuth failed: {tj.get('error_description') or tj.get('error') or ('HTTP ' + str(tr.status_code))}")
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.ok:
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.57",
3
+ "version": "1.1.60",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Melaya Labs LLC",