@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.
- package/dist/assistantHost.py +126 -5
- package/dist/connection.d.ts +1 -1
- package/dist/connection.js +245 -106
- package/dist/detect.js +1 -1
- package/dist/localDbProbe.py +17 -9
- package/dist/pythonEnv.js +10 -9
- package/dist/sharedVendor.js +15 -1
- package/localDbProbe.py +17 -9
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -198,15 +198,22 @@ def _render_summary_text(summary) -> str:
|
|
|
198
198
|
# this to emit delta / tool events keyed to the turn in flight. `cancel` is the
|
|
199
199
|
# STOP flag: set by the stdin-reader thread, polled by the running turn.
|
|
200
200
|
_stream = {"turnId": "", "lens": {}, "cancel": False, "usedBrowser": False,
|
|
201
|
-
"deltaBuf": "", "deltaMid": ""
|
|
201
|
+
"deltaBuf": "", "deltaMid": "", "deltaAt": 0.0,
|
|
202
|
+
"thinkLens": {}, "toolIds": set()}
|
|
202
203
|
|
|
203
204
|
# Coalesce per-token pre_print fragments before emitting a `delta`. agentscope
|
|
204
205
|
# hands us one cumulative snapshot per model chunk; a streaming Anthropic/OpenAI
|
|
205
206
|
# response fragments the answer into 2-15 char pieces, and emitting a MELASSIST
|
|
206
207
|
# line (→ socket → server → SSE → client) PER fragment made streaming read as
|
|
207
208
|
# ultra-choppy / ultra-slow (hundreds of round-trips for one answer). Hold the
|
|
208
|
-
# suffix
|
|
209
|
-
|
|
209
|
+
# suffix briefly, then flush one delta. The flush is TIME-based (every
|
|
210
|
+
# _DELTA_FLUSH_MS once a few chars are buffered) rather than waiting for a whole
|
|
211
|
+
# clause: the old 48-char threshold made the answer appear in visible bursts of
|
|
212
|
+
# ~10 tokens. 35 ms is below what the eye reads as chunky while still merging
|
|
213
|
+
# the 2-15 char fragments a fast model emits in the same instant.
|
|
214
|
+
_DELTA_FLUSH_CHARS = 48 # always flush once this much is buffered
|
|
215
|
+
_DELTA_FLUSH_MIN_CHARS = 6 # time-based flush needs at least this much
|
|
216
|
+
_DELTA_FLUSH_MS = 35
|
|
210
217
|
|
|
211
218
|
# Sentinel returned by a turn's coroutine when the user pressed STOP.
|
|
212
219
|
_CANCELLED = object()
|
|
@@ -465,6 +472,50 @@ def _emit(turn_id: str, kind: str, **fields) -> None:
|
|
|
465
472
|
pass
|
|
466
473
|
|
|
467
474
|
|
|
475
|
+
def _split_stream_blocks(content):
|
|
476
|
+
"""Split a streamed message's content into (answer_text, thinking_text,
|
|
477
|
+
[(tool_id, tool_name)]).
|
|
478
|
+
|
|
479
|
+
The generic _flatten_agent_text folds thinking text AND tool_use `input`
|
|
480
|
+
values into one string. That was harmless while models only streamed text,
|
|
481
|
+
but now that the model wrappers also stream reasoning (Codex summaries) and
|
|
482
|
+
partial tool calls (Claude), flattening would leak reasoning and tool
|
|
483
|
+
arguments into the answer bubble. Typed blocks are routed by type; any other
|
|
484
|
+
shape falls back to the old flattening, so plain-text providers are
|
|
485
|
+
unchanged."""
|
|
486
|
+
from shared.runtime.events import _flatten_agent_text
|
|
487
|
+
if content is None:
|
|
488
|
+
return "", "", []
|
|
489
|
+
if isinstance(content, str):
|
|
490
|
+
return content, "", []
|
|
491
|
+
blocks = content if isinstance(content, (list, tuple)) else [content]
|
|
492
|
+
text_parts: list = []
|
|
493
|
+
think_parts: list = []
|
|
494
|
+
tools: list = []
|
|
495
|
+
for b in blocks:
|
|
496
|
+
btype = b.get("type") if isinstance(b, dict) else getattr(b, "type", None)
|
|
497
|
+
if btype == "text":
|
|
498
|
+
v = b.get("text") if isinstance(b, dict) else getattr(b, "text", "")
|
|
499
|
+
if v:
|
|
500
|
+
text_parts.append(str(v))
|
|
501
|
+
elif btype == "thinking":
|
|
502
|
+
v = b.get("thinking") if isinstance(b, dict) else getattr(b, "thinking", "")
|
|
503
|
+
if v:
|
|
504
|
+
think_parts.append(str(v))
|
|
505
|
+
elif btype == "tool_use":
|
|
506
|
+
tid = b.get("id") if isinstance(b, dict) else getattr(b, "id", "")
|
|
507
|
+
nm = b.get("name") if isinstance(b, dict) else getattr(b, "name", "")
|
|
508
|
+
if nm:
|
|
509
|
+
tools.append((str(tid or nm), str(nm)))
|
|
510
|
+
elif btype in ("image", "audio", "video", "tool_result"):
|
|
511
|
+
continue
|
|
512
|
+
else:
|
|
513
|
+
v = _flatten_agent_text(b)
|
|
514
|
+
if v:
|
|
515
|
+
text_parts.append(v)
|
|
516
|
+
return "".join(text_parts), "".join(think_parts), tools
|
|
517
|
+
|
|
518
|
+
|
|
468
519
|
def _flush_delta(turn_id: str) -> None:
|
|
469
520
|
"""Emit any buffered pre_print suffix as one `delta` and clear the buffer.
|
|
470
521
|
|
|
@@ -475,6 +526,7 @@ def _flush_delta(turn_id: str) -> None:
|
|
|
475
526
|
if buf and turn_id:
|
|
476
527
|
_emit(turn_id, "delta", content=buf)
|
|
477
528
|
_stream["deltaBuf"] = ""
|
|
529
|
+
_stream["deltaAt"] = time.monotonic()
|
|
478
530
|
|
|
479
531
|
|
|
480
532
|
def _log(msg: str) -> None:
|
|
@@ -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 =
|
|
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
|
-
|
|
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().
|
package/dist/connection.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Heartbeat to keep the connection alive
|
|
11
11
|
* - Graceful disconnect on Ctrl+C
|
|
12
12
|
*/
|
|
13
|
-
import type
|
|
13
|
+
import { type DetectedModel } from "./detect.js";
|
|
14
14
|
interface ConnectOpts {
|
|
15
15
|
token: string;
|
|
16
16
|
serverUrl: string;
|
package/dist/connection.js
CHANGED
|
@@ -25,6 +25,7 @@ import { startLocalRelay, setActiveProject } from "./localRelay.js";
|
|
|
25
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
26
|
const __dirname = dirname(__filename);
|
|
27
27
|
import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
|
|
28
|
+
import { detectModels } from "./detect.js";
|
|
28
29
|
import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
|
|
29
30
|
// Melaya Browser (plan Sections 3/4/7): the governed browser control
|
|
30
31
|
// bridge + its runner-side grant verifier and engine discovery.
|
|
@@ -76,6 +77,10 @@ let warmSpare = null;
|
|
|
76
77
|
// the most recent real assistant_start so the spare matches the user's actual
|
|
77
78
|
// usage. null until the first real boot teaches us a config.
|
|
78
79
|
let warmSpareSeed = null;
|
|
80
|
+
function _assistantCredHash(c) {
|
|
81
|
+
const entries = Object.entries(c || {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
82
|
+
return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 16);
|
|
83
|
+
}
|
|
79
84
|
// PR4: sid-keyed boot reservation. The guard-then-spawn in runner:assistant_start
|
|
80
85
|
// has an async gap (ensureSharedModules / ensurePythonEnv awaits) between the
|
|
81
86
|
// activeAssistants.has() check and the .set(). A second assistant_start in that
|
|
@@ -177,10 +182,38 @@ export async function connect(opts) {
|
|
|
177
182
|
// first assistant chat spawns near-instantly. No-op on a fresh runner (no
|
|
178
183
|
// shared bundle on disk yet) — the real assistant_start installs it then.
|
|
179
184
|
let _assistantPrewarmed = false;
|
|
185
|
+
// Resolve the installed Claude Code version ONCE per runner process and export
|
|
186
|
+
// it as MEL_CLAUDE_CODE_VERSION, which every assistant host inherits. Without
|
|
187
|
+
// it each host ran a blocking `claude --version` (slow on Windows, through a
|
|
188
|
+
// .cmd shim) on its first model call. Fixed argv, no user input.
|
|
189
|
+
const _detectClaudeCodeVersion = async () => {
|
|
190
|
+
if (process.env.MEL_CLAUDE_CODE_VERSION)
|
|
191
|
+
return;
|
|
192
|
+
const out = await new Promise((resolve) => {
|
|
193
|
+
let buf = "";
|
|
194
|
+
try {
|
|
195
|
+
const p = spawn("claude", ["--version"], { shell: process.platform === "win32", stdio: ["ignore", "pipe", "ignore"] });
|
|
196
|
+
const timer = setTimeout(() => { try {
|
|
197
|
+
p.kill();
|
|
198
|
+
}
|
|
199
|
+
catch { /* gone */ } resolve(buf); }, 15_000);
|
|
200
|
+
p.stdout?.on("data", (d) => { buf += String(d); });
|
|
201
|
+
p.on("error", () => { clearTimeout(timer); resolve(""); });
|
|
202
|
+
p.on("close", () => { clearTimeout(timer); resolve(buf); });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
resolve("");
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
const m = out.match(/\d+\.\d+\.\d+/);
|
|
209
|
+
if (m)
|
|
210
|
+
process.env.MEL_CLAUDE_CODE_VERSION = m[0];
|
|
211
|
+
};
|
|
180
212
|
const _prewarmAssistantEnv = async () => {
|
|
181
213
|
if (_assistantPrewarmed)
|
|
182
214
|
return;
|
|
183
215
|
_assistantPrewarmed = true;
|
|
216
|
+
void _detectClaudeCodeVersion();
|
|
184
217
|
try {
|
|
185
218
|
const { getLocalSharedVersion } = await import("./sharedVendor.js");
|
|
186
219
|
let localVersion = getLocalSharedVersion();
|
|
@@ -254,6 +287,30 @@ export async function connect(opts) {
|
|
|
254
287
|
console.log(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
|
|
255
288
|
}
|
|
256
289
|
};
|
|
290
|
+
// Models were detected ONCE at startup, so a model a CLI added later (e.g.
|
|
291
|
+
// gpt-6-astra landing in ~/.codex/models_cache.json after the runner started,
|
|
292
|
+
// 2026-10-03) never reached the picker until a runner restart. Re-detect every
|
|
293
|
+
// 5 minutes and push the list when it changed. File reads only (plus the
|
|
294
|
+
// Claude model list call), no subprocess.
|
|
295
|
+
let modelRefresh = null;
|
|
296
|
+
const startModelRefresh = () => {
|
|
297
|
+
if (modelRefresh)
|
|
298
|
+
return;
|
|
299
|
+
modelRefresh = setInterval(async () => {
|
|
300
|
+
try {
|
|
301
|
+
const fresh = await detectModels();
|
|
302
|
+
if (JSON.stringify(fresh) === JSON.stringify(opts.models))
|
|
303
|
+
return;
|
|
304
|
+
opts.models = fresh;
|
|
305
|
+
if (socket.connected)
|
|
306
|
+
socket.emit("runner:models", fresh);
|
|
307
|
+
if (opts.verbose)
|
|
308
|
+
console.log(chalk.gray(` [models] refreshed: ${fresh.length} model(s)`));
|
|
309
|
+
}
|
|
310
|
+
catch { /* keep the last good list */ }
|
|
311
|
+
}, 5 * 60_000);
|
|
312
|
+
modelRefresh.unref?.();
|
|
313
|
+
};
|
|
257
314
|
socket.on("connect", async () => {
|
|
258
315
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
259
316
|
connectedAt = Date.now();
|
|
@@ -264,6 +321,7 @@ export async function connect(opts) {
|
|
|
264
321
|
socketGeneration += 1;
|
|
265
322
|
// Report detected models
|
|
266
323
|
socket.emit("runner:models", opts.models);
|
|
324
|
+
startModelRefresh();
|
|
267
325
|
// Discover installed browser engines (Chrome/Edge/Brave/bundled
|
|
268
326
|
// Chromium) so the hello can advertise the browser_control capability
|
|
269
327
|
// with an accurate engine + CDP report (plan Section 4). Best-effort:
|
|
@@ -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:
|
|
931
|
-
//
|
|
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
|
-
|
|
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
|
-
} },
|
|
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.
|
|
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
|
|
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":
|
|
35
|
+
"User-Agent": `claude-code/${process.env.MEL_CLAUDE_CODE_VERSION || "2.1.280"}`,
|
|
36
36
|
},
|
|
37
37
|
signal: AbortSignal.timeout(6000),
|
|
38
38
|
});
|
package/dist/localDbProbe.py
CHANGED
|
@@ -205,13 +205,20 @@ def probe_snowflake(creds):
|
|
|
205
205
|
|
|
206
206
|
|
|
207
207
|
# ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
|
|
208
|
-
def probe_databricks(creds):
|
|
209
|
-
|
|
208
|
+
def probe_databricks(creds):
|
|
209
|
+
import re
|
|
210
|
+
from urllib.parse import urlsplit
|
|
211
|
+
host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
|
|
210
212
|
pat = _pick(creds, "token", "DATABRICKS_TOKEN")
|
|
211
213
|
cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
|
|
212
214
|
sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
|
|
213
|
-
if not host:
|
|
214
|
-
_out(False, "Workspace URL required")
|
|
215
|
+
if not host:
|
|
216
|
+
_out(False, "Workspace URL required")
|
|
217
|
+
url = urlsplit(host)
|
|
218
|
+
if (url.scheme != "https" or url.username or url.password or url.port or url.path or url.query or url.fragment
|
|
219
|
+
or not re.fullmatch(r"(?!accounts(?:\.|-))[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:cloud\.databricks\.com|azuredatabricks\.net|gcp\.databricks\.com)", url.hostname or "")):
|
|
220
|
+
_out(False, "Use an HTTPS Databricks workspace URL without a path or query")
|
|
221
|
+
telemetry = {"User-Agent": "MelayaLabs_Melaya/2.0.0"}
|
|
215
222
|
try:
|
|
216
223
|
import requests # type: ignore
|
|
217
224
|
except ImportError:
|
|
@@ -224,7 +231,8 @@ def probe_databricks(creds):
|
|
|
224
231
|
tr = requests.post(
|
|
225
232
|
f"{host}/oidc/v1/token",
|
|
226
233
|
auth=(cid, sec),
|
|
227
|
-
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
234
|
+
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
235
|
+
headers=telemetry, allow_redirects=False,
|
|
228
236
|
timeout=12,
|
|
229
237
|
)
|
|
230
238
|
tj = {}
|
|
@@ -232,15 +240,15 @@ def probe_databricks(creds):
|
|
|
232
240
|
tj = tr.json()
|
|
233
241
|
except Exception: # noqa: BLE001
|
|
234
242
|
pass
|
|
235
|
-
if
|
|
236
|
-
_out(False, f"Databricks OAuth failed:
|
|
243
|
+
if tr.status_code != 200 or not tj.get("access_token"):
|
|
244
|
+
_out(False, f"Databricks OAuth failed: HTTP {tr.status_code}")
|
|
237
245
|
bearer = tj["access_token"]
|
|
238
246
|
r = requests.get(
|
|
239
247
|
f"{host}/api/2.1/clusters/list?page_size=1",
|
|
240
|
-
headers={"Authorization": f"Bearer {bearer}"},
|
|
248
|
+
headers={"Authorization": f"Bearer {bearer}", **telemetry}, allow_redirects=False,
|
|
241
249
|
timeout=12,
|
|
242
250
|
)
|
|
243
|
-
if r.
|
|
251
|
+
if r.status_code == 200:
|
|
244
252
|
_out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
|
|
245
253
|
_out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
|
|
246
254
|
except Exception as e: # noqa: BLE001
|
package/dist/pythonEnv.js
CHANGED
|
@@ -345,18 +345,19 @@ async function _resolveAndCacheCertBundle(onProgress) {
|
|
|
345
345
|
onProgress("(warn: certifi.where() did not resolve a valid path — HTTPS calls may fail with cert verify errors)");
|
|
346
346
|
}
|
|
347
347
|
}
|
|
348
|
-
// The marker is keyed on
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
348
|
+
// The marker is keyed on a hash of PIP_DEPS only: additions, removals and
|
|
349
|
+
// version-pin changes (e.g. scrapling[fetchers] in 1.0.32) invalidate the venv.
|
|
350
|
+
// It used to also include sharedVersion, a hash of every shared .py file, so
|
|
351
|
+
// EVERY agents deploy forced the full rebuild (pip upgrade, 67-dep install,
|
|
352
|
+
// scrapling browsers, nltk, certs: 1-2 min on the first assistant turn) even
|
|
353
|
+
// though the venv holds no shared code (the bundle is read from its own cache
|
|
354
|
+
// dir) and the dep list had not changed. The `_expectedVersion` parameter is
|
|
355
|
+
// kept so callers stay unchanged.
|
|
355
356
|
function _pipDepsHash() {
|
|
356
357
|
return createHash("sha256").update(JSON.stringify(PIP_DEPS)).digest("hex").slice(0, 12);
|
|
357
358
|
}
|
|
358
|
-
function venvMarkerValue(
|
|
359
|
-
return
|
|
359
|
+
function venvMarkerValue(_expectedVersion) {
|
|
360
|
+
return `deps::${_pipDepsHash()}`;
|
|
360
361
|
}
|
|
361
362
|
function venvIsValid(expectedVersion) {
|
|
362
363
|
if (!existsSync(venvPython()))
|
package/dist/sharedVendor.js
CHANGED
|
@@ -51,7 +51,21 @@ const CANARY_FILES = [
|
|
|
51
51
|
function cacheLooksValid() {
|
|
52
52
|
return CANARY_FILES.every((p) => existsSync(p));
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
// Single-flight per version: the server now also pushes `runner:prefetch_bundle`
|
|
55
|
+
// after a deploy and on connect, so a background prefetch can overlap a real
|
|
56
|
+
// assistant/pipeline start. Two concurrent downloads would write the same cache
|
|
57
|
+
// dir at once; callers for the same version share one download instead.
|
|
58
|
+
const _sharedInFlight = new Map();
|
|
59
|
+
export function ensureSharedModules(serverUrl, expectedVersion, runnerToken) {
|
|
60
|
+
const existing = _sharedInFlight.get(expectedVersion);
|
|
61
|
+
if (existing)
|
|
62
|
+
return existing;
|
|
63
|
+
const p = _ensureSharedModulesImpl(serverUrl, expectedVersion, runnerToken)
|
|
64
|
+
.finally(() => { _sharedInFlight.delete(expectedVersion); });
|
|
65
|
+
_sharedInFlight.set(expectedVersion, p);
|
|
66
|
+
return p;
|
|
67
|
+
}
|
|
68
|
+
async function _ensureSharedModulesImpl(serverUrl, expectedVersion, runnerToken) {
|
|
55
69
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
56
70
|
// Check cached version. We require BOTH a version match AND a valid nested
|
|
57
71
|
// layout — older runner versions wrote flat filenames into shared/, and
|
package/localDbProbe.py
CHANGED
|
@@ -205,13 +205,20 @@ def probe_snowflake(creds):
|
|
|
205
205
|
|
|
206
206
|
|
|
207
207
|
# ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
|
|
208
|
-
def probe_databricks(creds):
|
|
209
|
-
|
|
208
|
+
def probe_databricks(creds):
|
|
209
|
+
import re
|
|
210
|
+
from urllib.parse import urlsplit
|
|
211
|
+
host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
|
|
210
212
|
pat = _pick(creds, "token", "DATABRICKS_TOKEN")
|
|
211
213
|
cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
|
|
212
214
|
sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
|
|
213
|
-
if not host:
|
|
214
|
-
_out(False, "Workspace URL required")
|
|
215
|
+
if not host:
|
|
216
|
+
_out(False, "Workspace URL required")
|
|
217
|
+
url = urlsplit(host)
|
|
218
|
+
if (url.scheme != "https" or url.username or url.password or url.port or url.path or url.query or url.fragment
|
|
219
|
+
or not re.fullmatch(r"(?!accounts(?:\.|-))[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:cloud\.databricks\.com|azuredatabricks\.net|gcp\.databricks\.com)", url.hostname or "")):
|
|
220
|
+
_out(False, "Use an HTTPS Databricks workspace URL without a path or query")
|
|
221
|
+
telemetry = {"User-Agent": "MelayaLabs_Melaya/2.0.0"}
|
|
215
222
|
try:
|
|
216
223
|
import requests # type: ignore
|
|
217
224
|
except ImportError:
|
|
@@ -224,7 +231,8 @@ def probe_databricks(creds):
|
|
|
224
231
|
tr = requests.post(
|
|
225
232
|
f"{host}/oidc/v1/token",
|
|
226
233
|
auth=(cid, sec),
|
|
227
|
-
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
234
|
+
data={"grant_type": "client_credentials", "scope": "all-apis"},
|
|
235
|
+
headers=telemetry, allow_redirects=False,
|
|
228
236
|
timeout=12,
|
|
229
237
|
)
|
|
230
238
|
tj = {}
|
|
@@ -232,15 +240,15 @@ def probe_databricks(creds):
|
|
|
232
240
|
tj = tr.json()
|
|
233
241
|
except Exception: # noqa: BLE001
|
|
234
242
|
pass
|
|
235
|
-
if
|
|
236
|
-
_out(False, f"Databricks OAuth failed:
|
|
243
|
+
if tr.status_code != 200 or not tj.get("access_token"):
|
|
244
|
+
_out(False, f"Databricks OAuth failed: HTTP {tr.status_code}")
|
|
237
245
|
bearer = tj["access_token"]
|
|
238
246
|
r = requests.get(
|
|
239
247
|
f"{host}/api/2.1/clusters/list?page_size=1",
|
|
240
|
-
headers={"Authorization": f"Bearer {bearer}"},
|
|
248
|
+
headers={"Authorization": f"Bearer {bearer}", **telemetry}, allow_redirects=False,
|
|
241
249
|
timeout=12,
|
|
242
250
|
)
|
|
243
|
-
if r.
|
|
251
|
+
if r.status_code == 200:
|
|
244
252
|
_out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
|
|
245
253
|
_out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
|
|
246
254
|
except Exception as e: # noqa: BLE001
|