@melaya/runner 1.1.56 → 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.
@@ -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:
@@ -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 = _flatten_agent_text(getattr(msg, "content", msg))
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
- if len(_stream["deltaBuf"]) >= _DELTA_FLUSH_CHARS or "\n" in suffix:
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().
@@ -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:
@@ -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
- const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL === "1";
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
- } }, 10 * 60_000);
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.generation === generation) {
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 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
- };
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": "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
@@ -193,6 +193,10 @@ const PIP_DEPS = [
193
193
  // "pandas is not installed". Numpy is already in PIP_DEPS (line 57) so
194
194
  // the wheel install is fast.
195
195
  "pandas",
196
+ // matplotlib — branded chart PNGs rendered locally by msoffice.chart_image
197
+ // and the word_create `chart` block (no third-party chart API for client
198
+ // data). Mirrors the cloud image lock (matplotlib 3.10.8 + closure).
199
+ "matplotlib",
196
200
  // certifi — provides the CA root bundle that tools like gmail_send /
197
201
  // any other HTTPS API rely on for cert verification. On macOS Python
198
202
  // 3.x doesn't read the system keychain by default; without certifi +
@@ -341,18 +345,19 @@ async function _resolveAndCacheCertBundle(onProgress) {
341
345
  onProgress("(warn: certifi.where() did not resolve a valid path — HTTPS calls may fail with cert verify errors)");
342
346
  }
343
347
  }
344
- // The marker is keyed on sharedVersion + a hash of PIP_DEPS so changes
345
- // to either invalidate the venv. Without the PIP_DEPS half, a runner
346
- // update that adds a dep (e.g. scrapling[fetchers] in 1.0.32) would
347
- // silently skip the reinstall on any box whose sharedVersion hadn't
348
- // also changed — operators would update the runner but never see the
349
- // new deps. Hashing the full deps list catches additions, removals,
350
- // 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.
351
356
  function _pipDepsHash() {
352
357
  return createHash("sha256").update(JSON.stringify(PIP_DEPS)).digest("hex").slice(0, 12);
353
358
  }
354
- function venvMarkerValue(expectedVersion) {
355
- return `${expectedVersion}::${_pipDepsHash()}`;
359
+ function venvMarkerValue(_expectedVersion) {
360
+ return `deps::${_pipDepsHash()}`;
356
361
  }
357
362
  function venvIsValid(expectedVersion) {
358
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.56",
3
+ "version": "1.1.59",
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",