flockbay 0.10.15 → 0.10.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/codex/flockbayMcpStdioBridge.cjs +339 -0
  2. package/dist/codex/flockbayMcpStdioBridge.mjs +339 -0
  3. package/dist/{index--o4BPz5o.cjs → index-BxBuBx7C.cjs} +2706 -609
  4. package/dist/{index-CUp3juDS.mjs → index-CHm9r89K.mjs} +2707 -611
  5. package/dist/index.cjs +3 -5
  6. package/dist/index.mjs +3 -5
  7. package/dist/lib.cjs +7 -9
  8. package/dist/lib.d.cts +219 -531
  9. package/dist/lib.d.mts +219 -531
  10. package/dist/lib.mjs +7 -9
  11. package/dist/{runCodex-D3eT-TvB.cjs → runCodex-DuCGwO2K.cjs} +264 -43
  12. package/dist/{runCodex-o6PCbHQ7.mjs → runCodex-DudVDqNh.mjs} +263 -42
  13. package/dist/{runGemini-CBxZp6I7.cjs → runGemini-B25LZ4Cw.cjs} +64 -29
  14. package/dist/{runGemini-Bt0oEj_g.mjs → runGemini-Ddu8UCOS.mjs} +63 -28
  15. package/dist/{types-C-jnUdn_.cjs → types-CGQhv7Z-.cjs} +470 -1146
  16. package/dist/{types-DGd6ea2Z.mjs → types-DuhcLxar.mjs} +469 -1142
  17. package/package.json +1 -1
  18. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Private/Commands/UnrealMCPBlueprintCommands.cpp +195 -6
  19. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Private/Commands/UnrealMCPBlueprintNodeCommands.cpp +376 -5
  20. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Private/Commands/UnrealMCPCommandSchema.cpp +731 -0
  21. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Private/Commands/UnrealMCPCommonUtils.cpp +476 -8
  22. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Private/Commands/UnrealMCPEditorCommands.cpp +1518 -94
  23. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Private/MCPServerRunnable.cpp +7 -4
  24. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Private/UnrealMCPBridge.cpp +150 -112
  25. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Public/Commands/UnrealMCPBlueprintCommands.h +2 -1
  26. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Public/Commands/UnrealMCPBlueprintNodeCommands.h +4 -1
  27. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Public/Commands/UnrealMCPCommandSchema.h +42 -0
  28. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/Public/Commands/UnrealMCPEditorCommands.h +21 -0
  29. package/tools/unreal-mcp/upstream/MCPGameProject/Plugins/UnrealMCP/Source/UnrealMCP/UnrealMCP.Build.cs +4 -1
  30. package/dist/flockbayScreenshotGate-DJX3Is5d.mjs +0 -136
  31. package/dist/flockbayScreenshotGate-DkxU24cR.cjs +0 -138
@@ -3,7 +3,7 @@
3
3
  var chalk = require('chalk');
4
4
  var os = require('node:os');
5
5
  var node_crypto = require('node:crypto');
6
- var types = require('./types-C-jnUdn_.cjs');
6
+ var types = require('./types-CGQhv7Z-.cjs');
7
7
  var node_child_process = require('node:child_process');
8
8
  var path = require('node:path');
9
9
  var node_readline = require('node:readline');
@@ -15,20 +15,17 @@ var ink = require('ink');
15
15
  var React = require('react');
16
16
  var node_url = require('node:url');
17
17
  var axios = require('axios');
18
- require('node:events');
18
+ var node_events = require('node:events');
19
19
  require('socket.io-client');
20
- var tweetnacl = require('tweetnacl');
21
20
  var child_process = require('child_process');
22
21
  var crypto = require('crypto');
23
22
  var path$1 = require('path');
24
23
  var os$1 = require('os');
25
24
  require('node:net');
26
- require('expo-server-sdk');
27
25
  var fs$3 = require('fs');
28
26
  var psList = require('ps-list');
29
27
  var spawn = require('cross-spawn');
30
28
  var tmp = require('tmp');
31
- var qrcode = require('qrcode-terminal');
32
29
  var open = require('open');
33
30
  var fastify = require('fastify');
34
31
  var z = require('zod');
@@ -36,6 +33,7 @@ var fastifyTypeProviderZod = require('fastify-type-provider-zod');
36
33
  var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
37
34
  var node_http = require('node:http');
38
35
  var streamableHttp_js = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
36
+ require('tweetnacl');
39
37
  var http = require('http');
40
38
  var util = require('util');
41
39
 
@@ -298,27 +296,68 @@ const PLATFORM_SYSTEM_PROMPT = trimIdent(`
298
296
 
299
297
  UnrealMCP capability hygiene (avoid guessing):
300
298
  - UnrealMCP command sets can differ by plugin build/version.
301
- - When starting UnrealMCP work in a session (or after any "Unknown command" error), run \`mcp__flockbay__unreal_mcp_command\` with \`type: "get_plugin_info"\` and use only the returned \`commands\` list.
299
+ - When starting UnrealMCP work in a session (or after any "Unknown command" error), prefer:
300
+ - \`mcp__flockbay__unreal_mcp_list_capabilities\` (fast: build info + supported commands + capability flags)
301
+ - or \`mcp__flockbay__unreal_mcp_command\` with \`type: "get_plugin_info"\` (supported commands list)
302
+ - When you're unsure about required params or accepted aliases for a command, use:
303
+ - \`mcp__flockbay__unreal_mcp_get_command_schema\` (returns required keys + examples for the running plugin build)
302
304
  - This is guidance (not a hard block): you may try a command, but if it\u2019s not supported, immediately switch to the nearest supported command instead of guessing more commands.
303
305
 
306
+ UnrealMCP asset placement (avoid guessing \`/Game/...\` paths):
307
+ - If the user asks to place assets from the project\u2019s \`/Content\` folder, do NOT guess object paths.
308
+ - Use \`mcp__flockbay__unreal_mcp_search_assets\` to find the asset\u2019s \`objectPath\`, and optionally \`mcp__flockbay__unreal_mcp_get_asset_info\` to confirm its class.
309
+ - Place it via \`mcp__flockbay__unreal_mcp_place_asset\` (supports StaticMesh + Blueprint assets).
310
+ - If the user says \u201Cuse an asset pack\u201D, you may first call \`mcp__flockbay__unreal_mcp_list_asset_packs\` to discover top-level \`/Game\` folders, then search within the pack root.
311
+
312
+ Unreal spatial intent (stop guessing \u201Chere/there\u201D):
313
+ - For \u201Cput X here/there / in front of me / on that surface\u201D, prefer these primitives:
314
+ - \`mcp__flockbay__unreal_mcp_get_editor_context\` (map + selection + editor camera)
315
+ - \`mcp__flockbay__unreal_mcp_get_player_context\` (PIE player pawn + camera)
316
+ - \`mcp__flockbay__unreal_mcp_raycast_from_camera\` (turn \u201Cthat surface\u201D into hit location + normal)
317
+ - \`mcp__flockbay__unreal_mcp_raycast_down\` (drop-to-ground)
318
+ - \`mcp__flockbay__unreal_mcp_get_actor_transform\` / \`mcp__flockbay__unreal_mcp_get_actor_bounds\`
319
+ - If the user wants a one-call helper, use:
320
+ - \`mcp__flockbay__unreal_place_asset_relative\` (place asset relative to selection/camera/player/hit)
321
+ - \`mcp__flockbay__unreal_spawn_actor_relative\` (spawn actor relative to selection/camera/player/hit)
322
+
304
323
  UnrealMCP PIE safety (avoid editor crashes):
305
324
  - Before running editor-world mutation commands (spawn/delete actors, set properties/transforms, spawn Blueprint actors), check \`get_play_in_editor_status\`.
306
325
  - If PIE is running/queued, stop it (\`stop_play_in_editor\`) before retrying the mutation.
307
326
 
327
+ Unreal verify + diagnose (recommended validation after changes):
328
+ - After making changes (especially content/Blueprint changes), prefer this sequence:
329
+ 1) \`mcp__flockbay__unreal_mcp_compile_blueprints_all\`
330
+ 2) \`mcp__flockbay__unreal_mcp_map_check\`
331
+ 3) If C++ exists or was edited: \`mcp__flockbay__unreal_build_project\`
332
+ 4) \`mcp__flockbay__unreal_smoke_test\` (PIE windowed + screenshot validation)
333
+ - If any step fails: stop, report the structured failures, and propose the smallest fix.
334
+ - After the change is validated and before concluding, save editor changes:
335
+ - Prefer \`mcp__flockbay__unreal_mcp_save_all\` to save all dirty packages.
336
+ - Or use \`mcp__flockbay__unreal_mcp_command\` with \`type: "save_all"\` to save all dirty packages.
337
+ - If \`save_all\` reports still-dirty packages, do not end the request; resolve the save issue and retry.
338
+
308
339
  UnrealMCP editor health (detect crashes/offline):
309
340
  - If UnrealMCP requests fail to connect or time out, run the health check tool (if available) to determine whether the editor is reachable.
310
341
  - Treat "not reachable" as: Unreal Editor is closed/crashed/hung OR UnrealMCP plugin is disabled. The minimal fix is to reopen/restart the editor and enable the plugin.
311
342
 
312
- ## B) Screenshots (via UnrealMCP)
313
- Use UnrealMCP when the user asks for:
314
- - a screenshot of the editor viewport
315
- - visual proof of an in-editor change
343
+ Unreal Editor relaunch (manual, never auto-restart):
344
+ - The platform may detect that Unreal Editor crashed/unreachable and will automatically abort your current run and post a chat message.
345
+ - When that happens: STOP UnrealMCP calls, fix the project as needed, then (only when ready) relaunch Unreal Editor with \`mcp__flockbay__unreal_editor_launch\`.
346
+ - Do not \u201Cauto-restart\u201D in a loop. Relaunch is a deliberate action after fixes.
347
+
348
+ ## B) Screenshots (via UnrealMCP)
349
+ Use UnrealMCP when the user asks for:
350
+ - a screenshot of the editor viewport
351
+ - visual validation of an in-editor change
316
352
 
317
353
  How:
318
354
  - Use \`mcp__flockbay__unreal_mcp_command\` with \`type: "take_screenshot"\`.
319
355
  - Default save path is \`<Project>/Saved/Screenshots/Flockbay/\` (auto-generated filename). Optional params: \`filepath\` (absolute or relative filename) and/or \`filename\` (base name).
320
356
 
321
357
  Do not use screenshots as a default fallback for editor queries (e.g. \u201Clist actors\u201D).
358
+ Do not treat \u201Ctook a screenshot\u201D as \u201Cdone\u201D:
359
+ - Screenshots are primarily for YOU (the agent) to validate the change.
360
+ - If the screenshot does not clearly confirm the requirement, continue iterating (or run a different verify tool).
322
361
 
323
362
  # Non\u2011negotiables
324
363
 
@@ -337,9 +376,10 @@ const PLATFORM_SYSTEM_PROMPT = trimIdent(`
337
376
  # Evidence strategy (how to validate)
338
377
 
339
378
  - Choose the lightest evidence that answers the user\u2019s request.
340
- - Prefer \u201Cone proof\u201D over \u201Cmany proofs\u201D.
379
+ - Prefer \u201Cone validation\u201D over \u201Cmany validations\u201D.
341
380
  - If you need visual confirmation, ask for or produce a screenshot explicitly (don\u2019t sneak it in).
342
- - Always view the resulting image/proof after creating one, and judge the result for yourself if possible.
381
+ - Always view the resulting image after creating one, and judge the result for yourself (not just for the user).
382
+ - If the image is ambiguous or doesn\u2019t show the requirement, say what\u2019s missing and keep going (don\u2019t end the request prematurely).
343
383
 
344
384
  # Session hygiene
345
385
 
@@ -858,7 +898,7 @@ async function claudeLocalLauncher(session) {
858
898
  }
859
899
  await abort();
860
900
  }
861
- session.client.rpcHandlerManager.registerHandler("abort", doAbort);
901
+ session.client.rpcHandlerManager.registerHandler("cancel-generation", doAbort);
862
902
  session.client.rpcHandlerManager.registerHandler("switch", doSwitch);
863
903
  session.queue.setOnMessage((message, mode) => {
864
904
  doSwitch();
@@ -906,8 +946,6 @@ async function claudeLocalLauncher(session) {
906
946
  }
907
947
  } finally {
908
948
  exutFuture.resolve(void 0);
909
- session.client.rpcHandlerManager.registerHandler("abort", async () => {
910
- });
911
949
  session.client.rpcHandlerManager.registerHandler("switch", async () => {
912
950
  });
913
951
  session.queue.setOnMessage(null);
@@ -1234,7 +1272,7 @@ function buildDaemonSafeEnv(baseEnv, binPath) {
1234
1272
  env[pathKey] = [...prepend, ...existingParts].join(pathSep);
1235
1273
  return env;
1236
1274
  }
1237
- const __filename$1 = node_url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index--o4BPz5o.cjs', document.baseURI).href)));
1275
+ const __filename$1 = node_url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index-BxBuBx7C.cjs', document.baseURI).href)));
1238
1276
  const __dirname$1 = path.join(__filename$1, "..");
1239
1277
  function getGlobalClaudeVersion(claudeExecutable) {
1240
1278
  try {
@@ -1913,6 +1951,139 @@ function getLatestUserImages() {
1913
1951
  return latestUserImages;
1914
1952
  }
1915
1953
 
1954
+ function uniqPush(into, seen, value) {
1955
+ const v = value.trim();
1956
+ if (!v) return;
1957
+ if (seen.has(v)) return;
1958
+ seen.add(v);
1959
+ into.push(v);
1960
+ }
1961
+ function normalizeFilePathToken(token) {
1962
+ return token.trim().replace(/^['"`]+/, "").replace(/['"`]+$/, "").replace(/[),.;:'"`]+$/, "").trim();
1963
+ }
1964
+ function resolveCandidatePath(candidate, cwd) {
1965
+ const raw = normalizeFilePathToken(candidate);
1966
+ if (!raw) return raw;
1967
+ if (raw.startsWith("~/")) {
1968
+ const home = process.env.HOME || process.env.USERPROFILE || "";
1969
+ if (home) return path.join(home, raw.slice(2));
1970
+ }
1971
+ return path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
1972
+ }
1973
+ function isImageBlock(block) {
1974
+ if (!block || typeof block !== "object") return false;
1975
+ if (block.type !== "image") return false;
1976
+ if (typeof block.data === "string" && block.data.length > 0) return true;
1977
+ if (block.source && typeof block.source === "object" && typeof block.source.data === "string" && block.source.data.length > 0) return true;
1978
+ return false;
1979
+ }
1980
+ function tryParseJsonObjectWithViews(text) {
1981
+ const trimmed = text.trim();
1982
+ if (!trimmed) return null;
1983
+ try {
1984
+ const direct = JSON.parse(trimmed);
1985
+ if (direct && typeof direct === "object" && Array.isArray(direct.views)) return direct;
1986
+ } catch {
1987
+ }
1988
+ for (let i = trimmed.length - 1; i >= 0; i -= 1) {
1989
+ if (trimmed[i] !== "{") continue;
1990
+ const candidate = trimmed.slice(i);
1991
+ try {
1992
+ const parsed = JSON.parse(candidate);
1993
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed.views)) return parsed;
1994
+ } catch {
1995
+ }
1996
+ }
1997
+ return null;
1998
+ }
1999
+ function extractScreenshotViewsFromText(text, cwd) {
2000
+ const out = [];
2001
+ const seen = /* @__PURE__ */ new Set();
2002
+ const re = /(?:^|[\s"'(])(?:-\s*)?([^\s"'()]*Saved[\\/]+Screenshots[\\/]+Flockbay[\\/]+[^\s"'()]+\.(?:png|jpg|jpeg))/gi;
2003
+ for (const match of text.matchAll(re)) {
2004
+ const token = String(match[1] || "");
2005
+ const resolved = resolveCandidatePath(token, cwd);
2006
+ if (!resolved) continue;
2007
+ if (!resolved.includes(`${path.sep}Saved${path.sep}Screenshots${path.sep}Flockbay${path.sep}`) && !resolved.includes("Saved/Screenshots/Flockbay/") && !resolved.includes("Saved\\Screenshots\\Flockbay\\")) {
2008
+ continue;
2009
+ }
2010
+ uniqPush(out, seen, resolved);
2011
+ }
2012
+ if (out.length === 0 && (text.includes("Saved/Screenshots/Flockbay") || text.includes("Saved\\Screenshots\\Flockbay"))) {
2013
+ const filenameRe = /(?:^|[\s"'(\\/])(?:-\s*)?(Flockbay_[A-Za-z0-9_.-]+\.(?:png|jpg|jpeg))/gi;
2014
+ for (const match of text.matchAll(filenameRe)) {
2015
+ const filename = normalizeFilePathToken(String(match[1] || ""));
2016
+ if (!filename) continue;
2017
+ const rel = path.join("Saved", "Screenshots", "Flockbay", filename);
2018
+ const resolved = resolveCandidatePath(rel, cwd);
2019
+ uniqPush(out, seen, resolved);
2020
+ }
2021
+ }
2022
+ return out;
2023
+ }
2024
+ function extractViewsFromParsedJson(parsed, cwd) {
2025
+ const out = [];
2026
+ const seen = /* @__PURE__ */ new Set();
2027
+ const views = Array.isArray(parsed?.views) ? parsed.views : [];
2028
+ for (const v of views) {
2029
+ const p = typeof v?.path === "string" ? v.path : "";
2030
+ if (!p.trim()) continue;
2031
+ uniqPush(out, seen, resolveCandidatePath(p, cwd));
2032
+ }
2033
+ return out;
2034
+ }
2035
+ function extractFromContentBlocks(content, cwd) {
2036
+ const texts = [];
2037
+ let hasImages = false;
2038
+ for (const block of content) {
2039
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
2040
+ texts.push(block.text);
2041
+ }
2042
+ if (!hasImages && isImageBlock(block)) hasImages = true;
2043
+ }
2044
+ const text = texts.join("\n");
2045
+ const parsed = tryParseJsonObjectWithViews(text);
2046
+ const jsonPaths = parsed ? extractViewsFromParsedJson(parsed, cwd) : [];
2047
+ const pathMatches = extractScreenshotViewsFromText(text, cwd);
2048
+ const combined = [];
2049
+ const seen = /* @__PURE__ */ new Set();
2050
+ for (const p of jsonPaths) uniqPush(combined, seen, p);
2051
+ for (const p of pathMatches) uniqPush(combined, seen, p);
2052
+ return { paths: combined, hasImages };
2053
+ }
2054
+ function detectScreenshotsForGate(args) {
2055
+ const cwd = args.cwd && args.cwd.trim().length > 0 ? args.cwd : process.cwd();
2056
+ const output = args.output;
2057
+ if (Array.isArray(output)) {
2058
+ const extracted = extractFromContentBlocks(output, cwd);
2059
+ return { paths: extracted.paths, hasImageBlocks: extracted.hasImages };
2060
+ }
2061
+ if (output && typeof output === "object" && Array.isArray(output.content)) {
2062
+ const extracted = extractFromContentBlocks(output.content, cwd);
2063
+ return { paths: extracted.paths, hasImageBlocks: extracted.hasImages };
2064
+ }
2065
+ if (output && typeof output === "object" && Array.isArray(output.views)) {
2066
+ return { paths: extractViewsFromParsedJson(output, cwd), hasImageBlocks: false };
2067
+ }
2068
+ const candidates = [
2069
+ typeof output === "string" ? output : null,
2070
+ typeof output?.stdout === "string" ? output.stdout : null,
2071
+ typeof output?.stderr === "string" ? output.stderr : null,
2072
+ typeof output?.output === "string" ? output.output : null,
2073
+ typeof output?.message === "string" ? output.message : null
2074
+ ];
2075
+ const combinedText = candidates.filter((v) => typeof v === "string" && v.trim().length > 0).join("\n");
2076
+ if (!combinedText) return { paths: [], hasImageBlocks: false };
2077
+ const parsed = tryParseJsonObjectWithViews(combinedText);
2078
+ const jsonPaths = parsed ? extractViewsFromParsedJson(parsed, cwd) : [];
2079
+ const pathMatches = extractScreenshotViewsFromText(combinedText, cwd);
2080
+ const out = [];
2081
+ const seen = /* @__PURE__ */ new Set();
2082
+ for (const p of jsonPaths) uniqPush(out, seen, p);
2083
+ for (const p of pathMatches) uniqPush(out, seen, p);
2084
+ return { paths: out, hasImageBlocks: false };
2085
+ }
2086
+
1916
2087
  function buildClaudeUserContent(text, images) {
1917
2088
  const cleanText = String(text ?? "").trim();
1918
2089
  if (images.length === 0) return cleanText;
@@ -2023,6 +2194,61 @@ async function claudeRemote(opts) {
2023
2194
  }
2024
2195
  };
2025
2196
  let messages = new PushableAsyncIterable();
2197
+ const screenshotGate = {
2198
+ paths: [],
2199
+ seen: /* @__PURE__ */ new Set(),
2200
+ hasImageBlocks: false,
2201
+ inAutoReview: false
2202
+ };
2203
+ const resetScreenshotGateForTurn = () => {
2204
+ screenshotGate.paths = [];
2205
+ screenshotGate.seen.clear();
2206
+ screenshotGate.hasImageBlocks = false;
2207
+ screenshotGate.inAutoReview = false;
2208
+ };
2209
+ const collectScreenshotsForGate = (output) => {
2210
+ if (!output) return;
2211
+ const detected = detectScreenshotsForGate({ output, cwd: opts.path });
2212
+ if (detected.paths.length === 0) return;
2213
+ if (detected.hasImageBlocks) screenshotGate.hasImageBlocks = true;
2214
+ for (const p of detected.paths) {
2215
+ const trimmed = String(p || "").trim();
2216
+ if (!trimmed) continue;
2217
+ if (screenshotGate.seen.has(trimmed)) continue;
2218
+ screenshotGate.seen.add(trimmed);
2219
+ screenshotGate.paths.push(trimmed);
2220
+ }
2221
+ };
2222
+ const buildScreenshotAutoReviewPrompt = (paths) => {
2223
+ const unique = Array.from(new Set(paths.map((p) => String(p || "").trim()).filter(Boolean)));
2224
+ const toolArgs = JSON.stringify(
2225
+ {
2226
+ paths: unique,
2227
+ limit: unique.length,
2228
+ upload: false,
2229
+ includeToolImages: true
2230
+ },
2231
+ null,
2232
+ 2
2233
+ );
2234
+ return trimIdent(`
2235
+ You just generated ${unique.length} screenshot${unique.length === 1 ? "" : "s"}.
2236
+
2237
+ Before doing anything else, call \`mcp__flockbay__read_images\` with:
2238
+ ${toolArgs}
2239
+
2240
+ Then visually inspect EVERY screenshot and report (this is for YOUR validation, not ceremony):
2241
+ 1) List 2\u20135 concrete acceptance criteria for the user's request.
2242
+ 2) One short bullet per image ("Image N: ...") describing what you see.
2243
+ 3) For each acceptance criterion: mark it as Verified / Not visible / Failed based ONLY on the screenshots.
2244
+ - If "Not visible", say what evidence is missing.
2245
+ - If "Failed", say what is wrong.
2246
+ 4) If anything is not Verified: state the next tool/action you will take to fix or validate it (do not claim completion yet).
2247
+
2248
+ Do not run any other tools in this step.
2249
+ `);
2250
+ };
2251
+ resetScreenshotGateForTurn();
2026
2252
  const initialParsed = extractUserImagesMarker(initial.message);
2027
2253
  const initialImages = initialParsed.hasImages ? getLatestUserImages().slice(0, initialParsed.count) : [];
2028
2254
  messages.push({
@@ -2097,6 +2323,17 @@ async function claudeRemote(opts) {
2097
2323
  if (message.type === "result") {
2098
2324
  updateThinking(false);
2099
2325
  types.logger.debug("[claudeRemote] Result received, exiting claudeRemote");
2326
+ if (!screenshotGate.inAutoReview && screenshotGate.paths.length > 0 && !screenshotGate.hasImageBlocks) {
2327
+ screenshotGate.inAutoReview = true;
2328
+ const reviewPrompt = buildScreenshotAutoReviewPrompt(screenshotGate.paths);
2329
+ messages.push({
2330
+ type: "user",
2331
+ message: { role: "user", content: reviewPrompt }
2332
+ });
2333
+ updateThinking(true);
2334
+ continue;
2335
+ }
2336
+ screenshotGate.inAutoReview = false;
2100
2337
  if (isCompactCommand) {
2101
2338
  types.logger.debug("[claudeRemote] Compaction completed");
2102
2339
  if (opts.onCompletionEvent) {
@@ -2111,14 +2348,19 @@ async function claudeRemote(opts) {
2111
2348
  return;
2112
2349
  }
2113
2350
  mode = next.mode;
2351
+ resetScreenshotGateForTurn();
2114
2352
  const parsed = extractUserImagesMarker(next.message);
2115
2353
  const nextImages = parsed.hasImages ? getLatestUserImages().slice(0, parsed.count) : [];
2116
2354
  messages.push({ type: "user", message: { role: "user", content: buildClaudeUserContent(parsed.text, nextImages) } });
2355
+ updateThinking(true);
2117
2356
  }
2118
2357
  if (message.type === "user") {
2119
2358
  const msg = message;
2120
2359
  if (msg.message.role === "user" && Array.isArray(msg.message.content)) {
2121
2360
  for (let c of msg.message.content) {
2361
+ if (c.type === "tool_result") {
2362
+ collectScreenshotsForGate(c.content ?? c);
2363
+ }
2122
2364
  if (c.type === "tool_result" && c.tool_use_id && opts.isAborted(c.tool_use_id)) {
2123
2365
  types.logger.debug("[claudeRemote] Tool aborted, exiting claudeRemote");
2124
2366
  return;
@@ -2665,11 +2907,14 @@ ${next}`
2665
2907
  }
2666
2908
  this.pendingRequests.clear();
2667
2909
  this.session.client.updateAgentState((currentState) => {
2668
- const pendingRequests = currentState.requests || {};
2669
- const completedRequests = { ...currentState.completedRequests };
2910
+ const pendingRaw = currentState?.requests;
2911
+ const pendingRequests = pendingRaw && typeof pendingRaw === "object" ? pendingRaw : {};
2912
+ const completedRaw = currentState?.completedRequests;
2913
+ const completedRequests = completedRaw && typeof completedRaw === "object" ? { ...completedRaw } : {};
2670
2914
  for (const [id, request] of Object.entries(pendingRequests)) {
2915
+ const reqObj = request && typeof request === "object" ? request : { value: request };
2671
2916
  completedRequests[id] = {
2672
- ...request,
2917
+ ...reqObj,
2673
2918
  completedAt: Date.now(),
2674
2919
  status: "canceled",
2675
2920
  reason: "Session switched to local mode"
@@ -3080,13 +3325,47 @@ class SDKToLogConverter {
3080
3325
  }
3081
3326
  }
3082
3327
 
3328
+ class AsyncLock {
3329
+ permits = 1;
3330
+ promiseResolverQueue = [];
3331
+ async inLock(func) {
3332
+ try {
3333
+ await this.lock();
3334
+ return await func();
3335
+ } finally {
3336
+ this.unlock();
3337
+ }
3338
+ }
3339
+ async lock() {
3340
+ if (this.permits > 0) {
3341
+ this.permits = this.permits - 1;
3342
+ return;
3343
+ }
3344
+ await new Promise((resolve) => this.promiseResolverQueue.push(resolve));
3345
+ }
3346
+ unlock() {
3347
+ this.permits += 1;
3348
+ if (this.permits > 1 && this.promiseResolverQueue.length > 0) {
3349
+ throw new Error("this.permits should never be > 0 when there is someone waiting.");
3350
+ } else if (this.permits === 1 && this.promiseResolverQueue.length > 0) {
3351
+ this.permits -= 1;
3352
+ const nextResolver = this.promiseResolverQueue.shift();
3353
+ if (nextResolver) {
3354
+ setTimeout(() => {
3355
+ nextResolver(true);
3356
+ }, 0);
3357
+ }
3358
+ }
3359
+ }
3360
+ }
3361
+
3083
3362
  class OutgoingMessageQueue {
3084
3363
  constructor(sendFunction) {
3085
3364
  this.sendFunction = sendFunction;
3086
3365
  }
3087
3366
  queue = [];
3088
3367
  nextId = 1;
3089
- lock = new types.AsyncLock();
3368
+ lock = new AsyncLock();
3090
3369
  processTimer;
3091
3370
  delayTimers = /* @__PURE__ */ new Map();
3092
3371
  /**
@@ -3271,7 +3550,7 @@ async function postJson(path, token, body) {
3271
3550
  const res = await fetch(`${base}${path}`, {
3272
3551
  method: "POST",
3273
3552
  headers: {
3274
- Authorization: `Bearer ${token}`,
3553
+ Authorization: `Machine ${token}`,
3275
3554
  "Content-Type": "application/json"
3276
3555
  },
3277
3556
  body: JSON.stringify(body ?? {})
@@ -3413,7 +3692,7 @@ async function claudeRemoteLauncher(session) {
3413
3692
  }
3414
3693
  await abort();
3415
3694
  }
3416
- session.client.rpcHandlerManager.registerHandler("abort", doAbort);
3695
+ session.client.rpcHandlerManager.registerHandler("cancel-generation", doAbort);
3417
3696
  session.client.rpcHandlerManager.registerHandler("switch", doSwitch);
3418
3697
  const permissionHandler = new PermissionHandler(session);
3419
3698
  const messageQueue = new OutgoingMessageQueue(
@@ -4269,7 +4548,9 @@ async function daemonPost(path, body) {
4269
4548
  }
4270
4549
  try {
4271
4550
  const timeoutRaw = process.env.FLOCKBAY_DAEMON_HTTP_TIMEOUT;
4272
- const timeout = timeoutRaw ? parseInt(timeoutRaw) : 1e4;
4551
+ const fallbackTimeout = path === "/spawn-session" ? 6e4 : 1e4;
4552
+ const parsedTimeout = timeoutRaw ? Number.parseInt(timeoutRaw, 10) : NaN;
4553
+ const timeout = Number.isFinite(parsedTimeout) ? parsedTimeout : fallbackTimeout;
4273
4554
  const response = await fetch(`http://127.0.0.1:${state.httpPort}${path}`, {
4274
4555
  method: "POST",
4275
4556
  headers: { "Content-Type": "application/json" },
@@ -4363,6 +4644,7 @@ async function stopDaemon() {
4363
4644
  try {
4364
4645
  await stopDaemonHttp();
4365
4646
  await waitForProcessDeath(state.pid, 2e3);
4647
+ await cleanupDaemonState();
4366
4648
  types.logger.debug("Daemon stopped gracefully via HTTP");
4367
4649
  return;
4368
4650
  } catch (error) {
@@ -4657,37 +4939,6 @@ ${typeLabels[type] || type}:`));
4657
4939
  console.log(chalk.green("\n\u2705 Doctor diagnosis complete!\n"));
4658
4940
  }
4659
4941
 
4660
- function displayQRCode(url) {
4661
- console.log("=".repeat(80));
4662
- console.log("\u{1F4F1} To authenticate, scan this QR code with your mobile device:");
4663
- console.log("=".repeat(80));
4664
- qrcode.generate(url, { small: true }, (qr) => {
4665
- for (let l of qr.split("\n")) {
4666
- console.log(" ".repeat(10) + l);
4667
- }
4668
- });
4669
- console.log("=".repeat(80));
4670
- }
4671
-
4672
- function generateWebAuthUrl(publicKey) {
4673
- const publicKeyBase64 = types.encodeBase64(publicKey, "base64url");
4674
- const server = encodeURIComponent(types.configuration.serverUrl);
4675
- const shouldAutoLogout = (() => {
4676
- try {
4677
- const serverUrl = new URL(types.configuration.serverUrl);
4678
- const webUrl = new URL(types.configuration.webappUrl);
4679
- const isLoopback = (host) => {
4680
- const h = String(host || "").toLowerCase();
4681
- return h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0" || h.endsWith(".localhost");
4682
- };
4683
- return isLoopback(serverUrl.hostname) && isLoopback(webUrl.hostname);
4684
- } catch {
4685
- return false;
4686
- }
4687
- })();
4688
- return `${types.configuration.webappUrl}/terminal/connect#key=${publicKeyBase64}&server=${server}${shouldAutoLogout ? "&autologout=1" : ""}`;
4689
- }
4690
-
4691
4942
  async function openBrowser(url) {
4692
4943
  try {
4693
4944
  const forceOpen = process.env.FLOCKBAY_FORCE_BROWSER === "1" || process.env.FLOCKBAY_FORCE_BROWSER === "true";
@@ -4705,217 +4956,78 @@ async function openBrowser(url) {
4705
4956
  }
4706
4957
  }
4707
4958
 
4708
- async function doAuth(options) {
4709
- console.clear();
4710
- const authMethod = options?.method === "mobile" ? "mobile" : "web";
4711
- const secret = new Uint8Array(node_crypto.randomBytes(32));
4712
- const keypair = tweetnacl.box.keyPair.fromSecretKey(secret);
4713
- try {
4714
- console.log(`[AUTH DEBUG] Sending auth request to: ${types.configuration.serverUrl}/v1/auth/request`);
4715
- console.log(`[AUTH DEBUG] Public key: ${types.encodeBase64(keypair.publicKey).substring(0, 20)}...`);
4716
- await axios.post(`${types.configuration.serverUrl}/v1/auth/request`, {
4717
- publicKey: types.encodeBase64(keypair.publicKey)
4718
- });
4719
- console.log(`[AUTH DEBUG] Auth request sent successfully`);
4720
- } catch (error) {
4721
- console.log(`[AUTH DEBUG] Failed to send auth request:`, error);
4722
- console.log("Failed to create authentication request, please try again later.");
4723
- return null;
4959
+ async function loginWithClerkAndPairMachine() {
4960
+ types.logger.debug("[AUTH] Starting Clerk-based CLI login + machine pairing");
4961
+ const settings = await types.updateSettings(async (s) => {
4962
+ const machineId2 = s.machineId || node_crypto.randomUUID();
4963
+ const serverUrl2 = s.serverUrl || types.configuration.serverUrl;
4964
+ const webappUrl = s.webappUrl || types.configuration.webappUrl;
4965
+ return { ...s, machineId: machineId2, serverUrl: serverUrl2, webappUrl };
4966
+ });
4967
+ const serverUrl = types.configuration.serverUrl.replace(/\/+$/, "");
4968
+ const machineId = String(settings.machineId || "").trim();
4969
+ if (!machineId) {
4970
+ throw new Error("Missing machineId (settings.json).");
4724
4971
  }
4725
- return authMethod === "mobile" ? await doMobileAuth(keypair) : await doWebAuth(keypair);
4726
- }
4727
- async function doMobileAuth(keypair) {
4728
- console.clear();
4729
- console.log("\nMobile Authentication\n");
4730
- console.log("Scan this QR code with your Flockbay mobile app:\n");
4731
- const authUrl = "flockbay://terminal?" + types.encodeBase64Url(keypair.publicKey);
4732
- displayQRCode(authUrl);
4733
- console.log("\nManual options:");
4734
- console.log(`- Paste this URL into the Flockbay app: ${authUrl}`);
4735
- console.log(`- Or open this link in a browser (works well on mobile web): ${generateWebAuthUrl(keypair.publicKey)}`);
4736
- console.log("");
4737
- return await waitForAuthentication(keypair);
4738
- }
4739
- async function doWebAuth(keypair) {
4740
- console.clear();
4741
- console.log("\nWeb Authentication\n");
4742
- const webUrl = generateWebAuthUrl(keypair.publicKey);
4743
- const localWebUrl = (() => {
4744
- try {
4745
- const u = new URL(webUrl);
4746
- u.protocol = "http:";
4747
- u.hostname = "localhost";
4748
- u.port = "8081";
4749
- return u.toString();
4750
- } catch {
4751
- return null;
4752
- }
4753
- })();
4754
- const isLocalServer = (() => {
4755
- try {
4756
- const server = new URL(types.configuration.serverUrl);
4757
- const host = server.hostname.toLowerCase();
4758
- return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host.endsWith(".localhost");
4759
- } catch {
4760
- return false;
4761
- }
4762
- })();
4763
- console.log("Opening your browser...");
4764
- const primaryUrl = isLocalServer && localWebUrl ? localWebUrl : webUrl;
4765
- const browserOpened = await openBrowser(primaryUrl);
4766
- if (browserOpened) {
4767
- console.log("\u2713 Browser opened\n");
4768
- console.log("Complete authentication in your browser window.");
4769
- } else {
4770
- console.log("Could not open browser automatically.");
4771
- }
4772
- console.log("\nIf the browser did not open, please copy and paste this URL:");
4773
- console.log(webUrl);
4774
- if (localWebUrl && localWebUrl !== webUrl) {
4775
- if (isLocalServer) {
4776
- console.log("\nLocal dev URL (same auth key + server):");
4777
- console.log(localWebUrl);
4778
- } else {
4779
- console.log("\nLocal dev note:");
4780
- console.log(
4781
- `- This terminal is waiting on ${types.configuration.serverUrl}, so opening the link on localhost will NOT complete auth.
4782
- - To authenticate locally, re-run with:
4783
- FLOCKBAY_SERVER_URL=http://localhost:3006 FLOCKBAY_WEBAPP_URL=http://localhost:8081`
4784
- );
4785
- }
4972
+ const res = await axios.post(
4973
+ `${serverUrl}/v1/cli/pair/request`,
4974
+ { machineId },
4975
+ { headers: { "Content-Type": "application/json" }, timeout: 3e4 }
4976
+ );
4977
+ const pairingId = String(res?.data?.pairingId || "").trim();
4978
+ const approveUrl = String(res?.data?.approveUrl || "").trim();
4979
+ if (!pairingId || !approveUrl) {
4980
+ throw new Error("Invalid pairing response from server.");
4786
4981
  }
4787
4982
  console.log("");
4788
- return await waitForAuthentication(keypair);
4789
- }
4790
- async function waitForAuthentication(keypair) {
4791
- process.stdout.write("Waiting for authentication");
4792
- let dots = 0;
4793
- let cancelled = false;
4794
- const handleInterrupt = () => {
4795
- cancelled = true;
4796
- console.log("\n\nAuthentication cancelled.");
4797
- process.exit(0);
4798
- };
4799
- process.on("SIGINT", handleInterrupt);
4800
- try {
4801
- while (!cancelled) {
4802
- try {
4803
- const response = await axios.post(`${types.configuration.serverUrl}/v1/auth/request`, { publicKey: types.encodeBase64(keypair.publicKey) });
4804
- if (response.data.state === "authorized") {
4805
- let token = response.data.token;
4806
- let r = types.decodeBase64(response.data.response);
4807
- let decrypted = decryptWithEphemeralKey(r, keypair.secretKey);
4808
- if (decrypted) {
4809
- if (decrypted[0] !== 0 || decrypted.length < 33) {
4810
- console.log("\n\nInvalid auth response (expected V2). Please update your app/CLI.");
4811
- return null;
4812
- }
4813
- const credentials = {
4814
- publicKey: decrypted.slice(1, 33),
4815
- machineKey: node_crypto.randomBytes(32),
4816
- token
4817
- };
4818
- await types.writeCredentialsDataKey(credentials);
4819
- console.log("\n\n\u2713 Authentication successful\n");
4820
- return {
4821
- encryption: {
4822
- type: "dataKey",
4823
- publicKey: credentials.publicKey,
4824
- machineKey: credentials.machineKey
4825
- },
4826
- token
4827
- };
4828
- } else {
4829
- console.log("\n\nFailed to decrypt response. Please try again.");
4830
- return null;
4831
- }
4832
- }
4833
- } catch (error) {
4834
- console.log("\n\nFailed to check authentication status. Please try again.");
4835
- return null;
4983
+ console.log(chalk.bold("Flockbay CLI login"));
4984
+ console.log(chalk.gray(`Profile: ${types.configuration.profile}`));
4985
+ console.log(chalk.gray(`Server: ${types.configuration.serverUrl}`));
4986
+ console.log(chalk.gray(`Machine: ${machineId} (${os.hostname()})`));
4987
+ console.log("");
4988
+ console.log("Open this link to sign in with Clerk and approve this machine:");
4989
+ console.log(approveUrl);
4990
+ console.log("");
4991
+ void openBrowser(approveUrl).catch(() => null);
4992
+ const deadline = Date.now() + 10 * 6e4;
4993
+ while (Date.now() < deadline) {
4994
+ await types.delay(1e3);
4995
+ const status = await axios.get(`${serverUrl}/v1/cli/pair/status`, {
4996
+ params: { pairingId, consume: 1 },
4997
+ timeout: 15e3
4998
+ });
4999
+ const state = String(status?.data?.state || "").trim();
5000
+ if (state === "approved") {
5001
+ const machineToken = String(status?.data?.machineToken || "").trim();
5002
+ const orgId = String(status?.data?.orgId || "").trim();
5003
+ if (!machineToken || !orgId) {
5004
+ throw new Error("Pairing approved, but missing token/orgId.");
4836
5005
  }
4837
- process.stdout.write("\rWaiting for authentication" + ".".repeat(dots % 3 + 1) + " ");
4838
- dots++;
4839
- await types.delay(1e3);
5006
+ const auth = { machineToken, orgId, createdAtMs: Date.now() };
5007
+ await types.writeCredentials(auth);
5008
+ console.log(chalk.green("\u2713 Machine paired to workspace"));
5009
+ console.log(chalk.gray(`Workspace: ${orgId}`));
5010
+ return auth;
5011
+ }
5012
+ if (state === "expired") throw new Error("Pairing expired. Re-run `flockbay login`.");
5013
+ if (state === "consumed") {
5014
+ throw new Error("Pairing token already consumed. If this is unexpected, re-run `flockbay login`.");
4840
5015
  }
4841
- } finally {
4842
- process.off("SIGINT", handleInterrupt);
4843
- }
4844
- return null;
4845
- }
4846
- function decryptWithEphemeralKey(encryptedBundle, recipientSecretKey) {
4847
- const ephemeralPublicKey = encryptedBundle.slice(0, 32);
4848
- const nonce = encryptedBundle.slice(32, 32 + tweetnacl.box.nonceLength);
4849
- const encrypted = encryptedBundle.slice(32 + tweetnacl.box.nonceLength);
4850
- const decrypted = tweetnacl.box.open(encrypted, nonce, ephemeralPublicKey, recipientSecretKey);
4851
- if (!decrypted) {
4852
- return null;
4853
5016
  }
4854
- return decrypted;
5017
+ throw new Error("Login timed out. Re-run `flockbay login`.");
4855
5018
  }
4856
- async function authAndSetupMachineIfNeeded(options) {
4857
- types.logger.debug("[AUTH] Starting auth and machine setup...");
4858
- const allowAuthFlow = options?.allowAuthFlow !== false;
4859
- const authMethod = options?.authMethod === "mobile" ? "mobile" : "web";
4860
- let credentials = await types.readCredentials();
4861
- if (!credentials) {
4862
- if (!allowAuthFlow) {
4863
- throw new Error('Not authenticated. Run "flockbay auth login" first.');
4864
- }
4865
- types.logger.debug("[AUTH] No credentials found, starting authentication flow...");
4866
- const authResult = await doAuth({ method: authMethod });
4867
- if (!authResult) {
4868
- throw new Error("Authentication failed or was cancelled");
4869
- }
4870
- credentials = authResult;
4871
- } else {
4872
- types.logger.debug("[AUTH] Using existing credentials");
5019
+ async function ensureMachineAuthOrLogin() {
5020
+ const existing = await types.readCredentials();
5021
+ const settings = await types.readSettings();
5022
+ const machineId = String(settings?.machineId || "").trim();
5023
+ if (existing && existing.machineToken && existing.orgId && machineId) {
5024
+ return { auth: existing, machineId };
4873
5025
  }
4874
- const canonicalizePath = (p) => String(p || "").trim().replace(/\/+$/, "");
4875
- const findMatchingExistingMachineId = async () => {
4876
- try {
4877
- const res = await axios.get(`${types.configuration.serverUrl}/v1/machines`, {
4878
- headers: { Authorization: `Bearer ${credentials.token}` }
4879
- });
4880
- const machines = Array.isArray(res.data) ? res.data : [];
4881
- const host = os.hostname();
4882
- const homeDir = os.homedir();
4883
- const flockbayHomeDir = canonicalizePath(types.configuration.flockbayHomeDir);
4884
- const matches = machines.map((m) => {
4885
- const raw = m?.metadata;
4886
- let metadata = raw;
4887
- if (typeof raw === "string") {
4888
- try {
4889
- metadata = JSON.parse(raw);
4890
- } catch {
4891
- metadata = null;
4892
- }
4893
- }
4894
- return { machine: m, metadata };
4895
- }).filter(({ machine, metadata }) => {
4896
- if (!machine?.id || !metadata) return false;
4897
- if (String(metadata.host || "") !== host) return false;
4898
- if (String(metadata.homeDir || "") !== homeDir) return false;
4899
- if (canonicalizePath(String(metadata.flockbayHomeDir || "")) !== flockbayHomeDir) return false;
4900
- return true;
4901
- }).map(({ machine }) => machine);
4902
- if (!matches.length) return null;
4903
- matches.sort((a, b) => (Number(a.createdAt) || 0) - (Number(b.createdAt) || 0));
4904
- return String(matches[0].id || "").trim() || null;
4905
- } catch {
4906
- return null;
4907
- }
4908
- };
4909
- const existingSettings = await types.readSettings();
4910
- const preferredMachineId = !existingSettings.machineId ? await findMatchingExistingMachineId() : null;
4911
- const settings = await types.updateSettings(async (s) => {
4912
- const machineId = s.machineId || preferredMachineId || node_crypto.randomUUID();
4913
- const serverUrl = s.serverUrl || types.configuration.serverUrl;
4914
- const webappUrl = s.webappUrl || types.configuration.webappUrl;
4915
- return { ...s, machineId, serverUrl, webappUrl };
4916
- });
4917
- types.logger.debug(`[AUTH] Machine ID: ${settings.machineId}`);
4918
- return { credentials, machineId: settings.machineId };
5026
+ const auth = await loginWithClerkAndPairMachine();
5027
+ const updated = await types.readSettings();
5028
+ const mid = String(updated?.machineId || "").trim();
5029
+ if (!mid) throw new Error("Missing machineId after login.");
5030
+ return { auth, machineId: mid };
4919
5031
  }
4920
5032
 
4921
5033
  function resolveTsxImportArgs(projectRoot) {
@@ -4995,6 +5107,7 @@ Error: ${message}` };
4995
5107
 
4996
5108
  function startDaemonControlServer({
4997
5109
  getChildren,
5110
+ getStatus,
4998
5111
  stopSession,
4999
5112
  spawnSession,
5000
5113
  requestShutdown,
@@ -5050,6 +5163,15 @@ function startDaemonControlServer({
5050
5163
  }))
5051
5164
  };
5052
5165
  });
5166
+ typed.post("/status", {
5167
+ schema: {
5168
+ response: {
5169
+ 200: z.z.any()
5170
+ }
5171
+ }
5172
+ }, async () => {
5173
+ return getStatus();
5174
+ });
5053
5175
  typed.post("/stop-session", {
5054
5176
  schema: {
5055
5177
  body: z.z.object({
@@ -5189,6 +5311,26 @@ function startDaemonControlServer({
5189
5311
  });
5190
5312
  }
5191
5313
 
5314
+ function shouldAutoRestart(params) {
5315
+ const { exit, stopRequested } = params;
5316
+ if (stopRequested) return false;
5317
+ const { code, signal } = exit;
5318
+ const cleanExit = code === 0 && !signal;
5319
+ if (cleanExit) return false;
5320
+ const expectedSignal = signal === "SIGTERM" || signal === "SIGINT";
5321
+ if (expectedSignal) return false;
5322
+ return true;
5323
+ }
5324
+ function computeNextAutoRestart(params) {
5325
+ const { nowMs, prev, config } = params;
5326
+ const withinWindow = prev && nowMs - prev.firstFailureAtMs <= config.windowMs;
5327
+ const state = withinWindow ? { ...prev } : { attempts: 0, firstFailureAtMs: nowMs };
5328
+ state.attempts += 1;
5329
+ if (state.attempts > config.maxAttempts) return { action: "give_up" };
5330
+ const delayMs = Math.min(config.baseDelayMs * 2 ** (state.attempts - 1), config.maxDelayMs);
5331
+ return { action: "schedule", delayMs, state };
5332
+ }
5333
+
5192
5334
  async function pathExists(p) {
5193
5335
  try {
5194
5336
  await fs$2.access(p);
@@ -5259,15 +5401,23 @@ const initialMachineMetadata = {
5259
5401
  flockbayLibDir: types.projectPath()
5260
5402
  };
5261
5403
  async function startDaemon() {
5404
+ let startupCompleted = false;
5405
+ let startupForceExitTimer = null;
5262
5406
  let requestShutdown;
5263
5407
  let resolvesWhenShutdownRequested = new Promise((resolve) => {
5264
5408
  requestShutdown = (source, errorMessage) => {
5265
5409
  types.logger.debug(`[DAEMON RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`);
5266
- setTimeout(async () => {
5267
- types.logger.debug("[DAEMON RUN] Startup malfunctioned, forcing exit with code 1");
5268
- await new Promise((resolve2) => setTimeout(resolve2, 100));
5269
- process.exit(1);
5270
- }, 1e3);
5410
+ if (!startupCompleted && !startupForceExitTimer) {
5411
+ startupForceExitTimer = setTimeout(async () => {
5412
+ types.logger.debug("[DAEMON RUN] Startup malfunctioned, forcing exit with code 1");
5413
+ await new Promise((resolve2) => setTimeout(resolve2, 100));
5414
+ process.exit(1);
5415
+ }, 1e4);
5416
+ try {
5417
+ startupForceExitTimer.unref();
5418
+ } catch {
5419
+ }
5420
+ }
5271
5421
  resolve({ source, errorMessage });
5272
5422
  };
5273
5423
  });
@@ -5321,7 +5471,7 @@ async function startDaemon() {
5321
5471
  if (caffeinateStarted) {
5322
5472
  types.logger.debug("[DAEMON RUN] Sleep prevention enabled");
5323
5473
  }
5324
- const { credentials, machineId } = await authAndSetupMachineIfNeeded({ allowAuthFlow: false });
5474
+ const { auth: credentials, machineId } = await ensureMachineAuthOrLogin();
5325
5475
  types.logger.debug("[DAEMON RUN] Auth and machine setup complete");
5326
5476
  const shouldStartUnrealMcp = String(process.env.FLOCKBAY_UNREAL_MCP_ENABLED || "").trim() === "1";
5327
5477
  if (shouldStartUnrealMcp) {
@@ -5334,10 +5484,55 @@ async function startDaemon() {
5334
5484
  }
5335
5485
  }
5336
5486
  const pidToTrackedSession = /* @__PURE__ */ new Map();
5487
+ const stopRequestedSessionIds = /* @__PURE__ */ new Set();
5488
+ const sessionAutoRestart = /* @__PURE__ */ new Map();
5489
+ const autoRestartConfig = {
5490
+ maxAttempts: parseInt(process.env.FLOCKBAY_DAEMON_AUTO_RESTART_MAX_ATTEMPTS || "5", 10),
5491
+ windowMs: parseInt(process.env.FLOCKBAY_DAEMON_AUTO_RESTART_WINDOW_MS || String(10 * 6e4), 10),
5492
+ baseDelayMs: parseInt(process.env.FLOCKBAY_DAEMON_AUTO_RESTART_BASE_DELAY_MS || "1000", 10),
5493
+ maxDelayMs: parseInt(process.env.FLOCKBAY_DAEMON_AUTO_RESTART_MAX_DELAY_MS || "30000", 10)
5494
+ };
5337
5495
  const pidToAwaiter = /* @__PURE__ */ new Map();
5338
5496
  let machineRef = null;
5339
- let api = null;
5497
+ let apiMachine = null;
5340
5498
  const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
5499
+ const findLatestLogForPid = async (pid) => {
5500
+ const suffix = `-pid-${pid}.log`;
5501
+ try {
5502
+ const files = await fs$1.readdir(types.configuration.logsDir);
5503
+ const matches = files.filter((f) => f.endsWith(suffix));
5504
+ if (matches.length === 0) return null;
5505
+ let best = null;
5506
+ for (const file of matches) {
5507
+ try {
5508
+ const st = await fs$1.stat(path$1.join(types.configuration.logsDir, file));
5509
+ const mtimeMs = Number(st.mtimeMs || 0);
5510
+ if (!best || mtimeMs > best.mtimeMs) best = { file, mtimeMs };
5511
+ } catch {
5512
+ }
5513
+ }
5514
+ return best ? path$1.join(types.configuration.logsDir, best.file) : null;
5515
+ } catch {
5516
+ return null;
5517
+ }
5518
+ };
5519
+ const readLogTail = async (path, maxBytes) => {
5520
+ try {
5521
+ const st = await fs$1.stat(path);
5522
+ const size = Number(st.size || 0);
5523
+ const offset = Math.max(0, size - maxBytes);
5524
+ const handle = await fs$1.open(path, "r");
5525
+ try {
5526
+ const buf = Buffer.alloc(Math.min(maxBytes, size));
5527
+ const { bytesRead } = await handle.read(buf, 0, buf.length, offset);
5528
+ return buf.subarray(0, bytesRead).toString("utf8");
5529
+ } finally {
5530
+ await handle.close();
5531
+ }
5532
+ } catch {
5533
+ return "";
5534
+ }
5535
+ };
5341
5536
  const onSessionWebhook = (sessionId, sessionMetadata) => {
5342
5537
  types.logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata);
5343
5538
  const pid = sessionMetadata.hostPid;
@@ -5351,6 +5546,9 @@ async function startDaemon() {
5351
5546
  if (existingSession && existingSession.startedBy === "daemon") {
5352
5547
  existingSession.serverSessionId = sessionId;
5353
5548
  existingSession.serverSessionMetadataFromLocalWebhook = sessionMetadata;
5549
+ if (existingSession.spawnOptions) {
5550
+ existingSession.spawnOptions = { ...existingSession.spawnOptions, sessionId };
5551
+ }
5354
5552
  types.logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`);
5355
5553
  const awaiter = pidToAwaiter.get(pid);
5356
5554
  if (awaiter) {
@@ -5389,16 +5587,7 @@ async function startDaemon() {
5389
5587
  const refreshBypassFromServerIfNeeded = async () => {
5390
5588
  if (bypassUeGates) return;
5391
5589
  if (envBypassUeGates) return;
5392
- if (!api) return;
5393
- const targetMachineId = requestedMachineId || machineRef?.id;
5394
- if (!targetMachineId) return;
5395
- try {
5396
- const latest = await api.getMachine(targetMachineId);
5397
- machineRef = latest;
5398
- bypassUeGates = envBypassUeGates || Boolean(latest?.metadata?.flockbayDevBypassUeGates);
5399
- } catch (err) {
5400
- bypassRefreshError = err instanceof Error ? err.message : String(err);
5401
- }
5590
+ bypassRefreshError = "refresh_not_supported";
5402
5591
  };
5403
5592
  const detection = await detectUnrealProject(directory);
5404
5593
  if (!detection.ok && !bypassUeGates) {
@@ -5419,7 +5608,7 @@ async function startDaemon() {
5419
5608
  const res = await fetch(url, {
5420
5609
  method: "POST",
5421
5610
  headers: {
5422
- Authorization: `Bearer ${credentials.token}`,
5611
+ Authorization: `Machine ${credentials.machineToken}`,
5423
5612
  "Content-Type": "application/json"
5424
5613
  },
5425
5614
  body: JSON.stringify(body ?? {})
@@ -5620,11 +5809,16 @@ Fix: restart session creation and re-select the project.`
5620
5809
  }
5621
5810
  const args = [
5622
5811
  agentCommand,
5812
+ "--profile",
5813
+ types.configuration.profile,
5623
5814
  "--flockbay-starting-mode",
5624
5815
  "remote",
5625
5816
  "--started-by",
5626
5817
  "daemon"
5627
5818
  ];
5819
+ if (options.sessionId) {
5820
+ args.push("--flockbay-session-id", String(options.sessionId));
5821
+ }
5628
5822
  const captureChildOutput = Boolean(process.env.DEBUG);
5629
5823
  const spawnEnv = {
5630
5824
  ...process.env,
@@ -5667,24 +5861,30 @@ Fix: restart session creation and re-select the project.`
5667
5861
  };
5668
5862
  }
5669
5863
  types.logger.debug(`[DAEMON RUN] Spawned process with PID ${sessionProcess.pid}`);
5864
+ const spawnOptionsForTracking = {
5865
+ ...options,
5866
+ approvedNewDirectoryCreation: true,
5867
+ coordination: coordinationForSpawn && typeof coordinationForSpawn === "object" ? coordinationForSpawn : options.coordination ?? null
5868
+ };
5670
5869
  const trackedSession = {
5671
5870
  startedBy: "daemon",
5672
5871
  pid: sessionProcess.pid,
5673
5872
  childProcess: sessionProcess,
5674
5873
  directoryCreated,
5874
+ spawnOptions: spawnOptionsForTracking,
5675
5875
  message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session there.` : void 0
5676
5876
  };
5677
5877
  pidToTrackedSession.set(sessionProcess.pid, trackedSession);
5678
5878
  sessionProcess.on("exit", (code, signal) => {
5679
5879
  types.logger.debug(`[DAEMON RUN] Child PID ${sessionProcess.pid} exited with code ${code}, signal ${signal}`);
5680
5880
  if (sessionProcess.pid) {
5681
- onChildExited(sessionProcess.pid);
5881
+ onChildExited(sessionProcess.pid, { code, signal });
5682
5882
  }
5683
5883
  });
5684
5884
  sessionProcess.on("error", (error) => {
5685
5885
  types.logger.debug(`[DAEMON RUN] Child process error:`, error);
5686
5886
  if (sessionProcess.pid) {
5687
- onChildExited(sessionProcess.pid);
5887
+ onChildExited(sessionProcess.pid, { code: null, signal: null });
5688
5888
  }
5689
5889
  });
5690
5890
  types.logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${sessionProcess.pid}`);
@@ -5707,17 +5907,25 @@ Fix: restart the daemon and try again (macOS: \`flockbay daemon stop\` then \`fl
5707
5907
  }, 3e4);
5708
5908
  const onEarlyExit = (code, signal) => {
5709
5909
  clearTimeout(timeout);
5710
- settleOnce({
5711
- type: "error",
5712
- errorMessage: `Session process exited before webhook (PID ${pid}, code ${code ?? "unknown"}, signal ${signal ?? "none"})`
5713
- });
5910
+ void (async () => {
5911
+ const logPath = await findLatestLogForPid(pid);
5912
+ settleOnce({
5913
+ type: "error",
5914
+ errorMessage: `Session process exited before webhook (PID ${pid}, code ${code ?? "unknown"}, signal ${signal ?? "none"}).
5915
+ Log: ${logPath || `not found (check ${types.configuration.logsDir})`}`
5916
+ });
5917
+ })();
5714
5918
  };
5715
5919
  const onEarlyError = (error) => {
5716
5920
  clearTimeout(timeout);
5717
- settleOnce({
5718
- type: "error",
5719
- errorMessage: `Session process error before webhook (PID ${pid}): ${error.message}`
5720
- });
5921
+ void (async () => {
5922
+ const logPath = await findLatestLogForPid(pid);
5923
+ settleOnce({
5924
+ type: "error",
5925
+ errorMessage: `Session process error before webhook (PID ${pid}): ${error.message}.
5926
+ Log: ${logPath || `not found (check ${types.configuration.logsDir})`}`
5927
+ });
5928
+ })();
5721
5929
  };
5722
5930
  sessionProcess.once("exit", onEarlyExit);
5723
5931
  sessionProcess.once("error", onEarlyError);
@@ -5767,8 +5975,13 @@ Fix: restart the daemon and try again (macOS: \`flockbay daemon stop\` then \`fl
5767
5975
  };
5768
5976
  const stopSession = (sessionId) => {
5769
5977
  types.logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`);
5978
+ if (sessionId && !sessionId.startsWith("PID-")) {
5979
+ stopRequestedSessionIds.add(sessionId);
5980
+ }
5770
5981
  for (const [pid, session] of pidToTrackedSession.entries()) {
5771
5982
  if (session.serverSessionId === sessionId || sessionId.startsWith("PID-") && pid === parseInt(sessionId.replace("PID-", ""))) {
5983
+ session.stopRequested = true;
5984
+ if (session.serverSessionId) stopRequestedSessionIds.add(session.serverSessionId);
5772
5985
  if (session.startedBy === "daemon" && session.childProcess) {
5773
5986
  try {
5774
5987
  session.childProcess.kill("SIGTERM");
@@ -5784,20 +5997,144 @@ Fix: restart the daemon and try again (macOS: \`flockbay daemon stop\` then \`fl
5784
5997
  types.logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error);
5785
5998
  }
5786
5999
  }
5787
- pidToTrackedSession.delete(pid);
5788
- types.logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`);
6000
+ types.logger.debug(`[DAEMON RUN] Stop requested for session ${sessionId}; waiting for exit to remove tracking`);
5789
6001
  return true;
5790
6002
  }
5791
6003
  }
5792
6004
  types.logger.debug(`[DAEMON RUN] Session ${sessionId} not found`);
5793
6005
  return false;
5794
6006
  };
5795
- const onChildExited = (pid) => {
6007
+ const scheduleAutoRestart = (params) => {
6008
+ const { sessionId, spawnOptions, reason } = params;
6009
+ if (stopRequestedSessionIds.has(sessionId)) {
6010
+ types.logger.debug(`[DAEMON RUN] Auto-restart suppressed (stop requested) for session ${sessionId}`);
6011
+ return;
6012
+ }
6013
+ const now = Date.now();
6014
+ const existing = sessionAutoRestart.get(sessionId);
6015
+ if (existing?.timer) {
6016
+ types.logger.debug(`[DAEMON RUN] Auto-restart already scheduled for session ${sessionId}`);
6017
+ return;
6018
+ }
6019
+ const next = computeNextAutoRestart({
6020
+ nowMs: now,
6021
+ prev: existing?.state ?? null,
6022
+ config: autoRestartConfig
6023
+ });
6024
+ if (next.action === "give_up") {
6025
+ types.logger.debug(
6026
+ `[DAEMON RUN] Auto-restart giving up for session ${sessionId} after ${autoRestartConfig.maxAttempts} attempt(s) within ${autoRestartConfig.windowMs}ms (last reason: ${reason})`
6027
+ );
6028
+ sessionAutoRestart.delete(sessionId);
6029
+ return;
6030
+ }
6031
+ const delay = next.delayMs;
6032
+ types.logger.debug(
6033
+ `[DAEMON RUN] Auto-restarting session ${sessionId} in ${delay}ms (attempt ${next.state.attempts}/${autoRestartConfig.maxAttempts}, reason: ${reason})`
6034
+ );
6035
+ const entry = { state: next.state, timer: void 0 };
6036
+ entry.timer = setTimeout(() => {
6037
+ entry.timer = void 0;
6038
+ sessionAutoRestart.set(sessionId, entry);
6039
+ if (stopRequestedSessionIds.has(sessionId)) {
6040
+ types.logger.debug(`[DAEMON RUN] Auto-restart canceled (stop requested) for session ${sessionId}`);
6041
+ return;
6042
+ }
6043
+ void (async () => {
6044
+ try {
6045
+ const result = await spawnSession({
6046
+ ...spawnOptions,
6047
+ approvedNewDirectoryCreation: true,
6048
+ sessionId
6049
+ });
6050
+ if (result.type === "success") {
6051
+ types.logger.debug(`[DAEMON RUN] Auto-restart succeeded for session ${sessionId}`);
6052
+ sessionAutoRestart.delete(sessionId);
6053
+ return;
6054
+ }
6055
+ types.logger.debug(
6056
+ `[DAEMON RUN] Auto-restart failed for session ${sessionId} (type=${result.type}): ${result.type === "error" ? result.errorMessage : "needs-user-approval"}`
6057
+ );
6058
+ } catch (err) {
6059
+ types.logger.debug(`[DAEMON RUN] Auto-restart threw for session ${sessionId}:`, err);
6060
+ }
6061
+ scheduleAutoRestart({ sessionId, spawnOptions, reason: "restart_failed" });
6062
+ })();
6063
+ }, delay);
6064
+ try {
6065
+ entry.timer.unref();
6066
+ } catch {
6067
+ }
6068
+ sessionAutoRestart.set(sessionId, entry);
6069
+ };
6070
+ const onChildExited = (pid, exit) => {
6071
+ const tracked = pidToTrackedSession.get(pid);
5796
6072
  types.logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`);
5797
6073
  pidToTrackedSession.delete(pid);
6074
+ if (!tracked) return;
6075
+ tracked.lastExit = {
6076
+ code: exit?.code ?? null,
6077
+ signal: exit?.signal ?? null,
6078
+ atMs: Date.now()
6079
+ };
6080
+ const sessionId = String(tracked.serverSessionId || "").trim();
6081
+ if (!sessionId) return;
6082
+ if (tracked.stopRequested || stopRequestedSessionIds.has(sessionId)) {
6083
+ types.logger.debug(`[DAEMON RUN] Not auto-restarting session ${sessionId} (stop requested)`);
6084
+ sessionAutoRestart.delete(sessionId);
6085
+ return;
6086
+ }
6087
+ const code = tracked.lastExit.code;
6088
+ const signal = tracked.lastExit.signal;
6089
+ if (!shouldAutoRestart({
6090
+ exit: { code, signal },
6091
+ stopRequested: Boolean(tracked.stopRequested || stopRequestedSessionIds.has(sessionId))
6092
+ })) {
6093
+ types.logger.debug(`[DAEMON RUN] Not auto-restarting session ${sessionId} (clean exit)`);
6094
+ sessionAutoRestart.delete(sessionId);
6095
+ return;
6096
+ }
6097
+ if (!tracked.spawnOptions) {
6098
+ types.logger.debug(`[DAEMON RUN] Not auto-restarting session ${sessionId} (missing spawn options)`);
6099
+ return;
6100
+ }
6101
+ void (async () => {
6102
+ const logPath = await findLatestLogForPid(pid);
6103
+ if (!logPath) return;
6104
+ const tail = await readLogTail(logPath, 16384);
6105
+ if (!tail.trim()) return;
6106
+ types.logger.debug(
6107
+ `[DAEMON RUN] Session runtime log tail for ${sessionId} (pid=${pid}, log=${path$1.basename(logPath)}):
6108
+ ` + tail
6109
+ );
6110
+ })();
6111
+ scheduleAutoRestart({
6112
+ sessionId,
6113
+ spawnOptions: tracked.spawnOptions,
6114
+ reason: `exit(code=${code ?? "null"},signal=${signal ?? "null"})`
6115
+ });
5798
6116
  };
5799
6117
  const { port: controlPort, stop } = await startDaemonControlServer({
5800
6118
  getChildren: getCurrentChildren,
6119
+ getStatus: () => ({
6120
+ profile: types.configuration.profile,
6121
+ serverUrl: types.configuration.serverUrl,
6122
+ webappUrl: types.configuration.webappUrl,
6123
+ orgId: credentials?.orgId || null,
6124
+ machineId,
6125
+ daemonPid: process.pid,
6126
+ daemonHttpPort: controlPort,
6127
+ startedWithCliVersion: types.packageJson.version,
6128
+ machine: machineRef ? { id: machineRef.id, seq: machineRef.seq || 0 } : null,
6129
+ connection: apiMachine ? apiMachine.getStatusSnapshot() : {
6130
+ connected: false,
6131
+ lastConnectError: null,
6132
+ lastDisconnectReason: null,
6133
+ lastHttpUpsertError: null,
6134
+ lastHttpUpsertStatus: null,
6135
+ lastHttpUpsertAt: null
6136
+ }
6137
+ }),
5801
6138
  stopSession,
5802
6139
  spawnSession,
5803
6140
  requestShutdown: () => requestShutdown("flockbay-cli"),
@@ -5819,93 +6156,89 @@ Fix: restart the daemon and try again (macOS: \`flockbay daemon stop\` then \`fl
5819
6156
  httpPort: controlPort,
5820
6157
  startedAt: Date.now()
5821
6158
  };
5822
- const apiClient = await types.ApiClient.create(credentials);
5823
- api = apiClient;
5824
- let machine;
5825
- try {
5826
- const existing = await apiClient.getMachine(machineId);
5827
- machine = await apiClient.getOrCreateMachine({
5828
- machineId,
5829
- metadata: { ...existing.metadata || {}, ...initialMachineMetadata },
5830
- daemonState: initialDaemonState
5831
- });
5832
- } catch (error) {
5833
- const status = error?.response?.status;
5834
- if (status === 404) {
5835
- machine = await apiClient.getOrCreateMachine({
5836
- machineId,
5837
- metadata: initialMachineMetadata,
5838
- daemonState: initialDaemonState
5839
- });
5840
- } else {
5841
- throw error;
5842
- }
5843
- }
5844
- types.logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`);
6159
+ const machine = {
6160
+ id: machineId,
6161
+ seq: 0,
6162
+ active: false,
6163
+ activeAt: null,
6164
+ createdAt: null,
6165
+ updatedAt: null,
6166
+ metadata: { ...initialMachineMetadata },
6167
+ daemonState: initialDaemonState
6168
+ };
5845
6169
  machineRef = machine;
5846
- const apiMachine = apiClient.machineSyncClient(machine);
6170
+ apiMachine = new types.ApiMachineClient(credentials.machineToken, machine);
5847
6171
  apiMachine.setRPCHandlers({
5848
6172
  spawnSession,
5849
6173
  stopSession,
5850
6174
  requestShutdown: () => requestShutdown("flockbay-app")
5851
6175
  });
5852
- apiMachine.connect();
6176
+ try {
6177
+ apiMachine.connect();
6178
+ } catch (err) {
6179
+ types.logger.debug("[DAEMON RUN] Failed to connect machine socket (will remain offline until restart):", err);
6180
+ }
5853
6181
  const heartbeatIntervalMs = parseInt(process.env.FLOCKBAY_DAEMON_HEARTBEAT_INTERVAL || "60000");
5854
6182
  let heartbeatRunning = false;
5855
6183
  const restartOnStaleVersionAndHeartbeat = setInterval(async () => {
5856
- if (heartbeatRunning) {
5857
- return;
5858
- }
6184
+ if (heartbeatRunning) return;
5859
6185
  heartbeatRunning = true;
5860
- if (process.env.DEBUG) {
5861
- types.logger.debug(`[DAEMON RUN] Health check started at ${(/* @__PURE__ */ new Date()).toLocaleString()}`);
5862
- }
5863
- for (const [pid, _] of pidToTrackedSession.entries()) {
6186
+ try {
6187
+ if (process.env.DEBUG) {
6188
+ types.logger.debug(`[DAEMON RUN] Health check started at ${(/* @__PURE__ */ new Date()).toLocaleString()}`);
6189
+ }
6190
+ for (const [pid, _] of pidToTrackedSession.entries()) {
6191
+ try {
6192
+ process.kill(pid, 0);
6193
+ } catch (error) {
6194
+ types.logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`);
6195
+ pidToTrackedSession.delete(pid);
6196
+ }
6197
+ }
5864
6198
  try {
5865
- process.kill(pid, 0);
6199
+ const projectVersion = JSON.parse(fs$3.readFileSync(path$1.join(types.projectPath(), "package.json"), "utf-8")).version;
6200
+ if (projectVersion !== types.configuration.currentCliVersion) {
6201
+ types.logger.debug("[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval");
6202
+ clearInterval(restartOnStaleVersionAndHeartbeat);
6203
+ try {
6204
+ spawnFlockbayCLI(["daemon", "start"], {
6205
+ detached: true,
6206
+ stdio: "ignore"
6207
+ });
6208
+ } catch (error) {
6209
+ types.logger.debug("[DAEMON RUN] Failed to spawn new daemon, this can happen during integration tests", error);
6210
+ }
6211
+ types.logger.debug("[DAEMON RUN] Hanging briefly - waiting for CLI to kill us due to stale version");
6212
+ await new Promise((resolve) => setTimeout(resolve, 1e4));
6213
+ process.exit(0);
6214
+ }
5866
6215
  } catch (error) {
5867
- types.logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`);
5868
- pidToTrackedSession.delete(pid);
6216
+ types.logger.debug("[DAEMON RUN] Failed to check CLI version during health check", error);
6217
+ }
6218
+ const daemonState = await types.readDaemonState();
6219
+ if (daemonState && daemonState.pid !== process.pid) {
6220
+ types.logger.debug("[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves.");
6221
+ requestShutdown("exception", "A different daemon was started without killing us. We should kill ourselves.");
5869
6222
  }
5870
- }
5871
- const projectVersion = JSON.parse(fs$3.readFileSync(path$1.join(types.projectPath(), "package.json"), "utf-8")).version;
5872
- if (projectVersion !== types.configuration.currentCliVersion) {
5873
- types.logger.debug("[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval");
5874
- clearInterval(restartOnStaleVersionAndHeartbeat);
5875
6223
  try {
5876
- spawnFlockbayCLI(["daemon", "start"], {
5877
- detached: true,
5878
- stdio: "ignore"
5879
- });
6224
+ const updatedState = {
6225
+ pid: process.pid,
6226
+ httpPort: controlPort,
6227
+ startTime: fileState.startTime,
6228
+ startedWithCliVersion: types.packageJson.version,
6229
+ lastHeartbeat: (/* @__PURE__ */ new Date()).toLocaleString(),
6230
+ daemonLogPath: fileState.daemonLogPath
6231
+ };
6232
+ types.writeDaemonState(updatedState);
6233
+ if (process.env.DEBUG) {
6234
+ types.logger.debug(`[DAEMON RUN] Health check completed at ${updatedState.lastHeartbeat}`);
6235
+ }
5880
6236
  } catch (error) {
5881
- types.logger.debug("[DAEMON RUN] Failed to spawn new daemon, this is quite likely to happen during integration tests as we are cleaning out dist/ directory", error);
5882
- }
5883
- types.logger.debug("[DAEMON RUN] Hanging for a bit - waiting for CLI to kill us because we are running outdated version of the code");
5884
- await new Promise((resolve) => setTimeout(resolve, 1e4));
5885
- process.exit(0);
5886
- }
5887
- const daemonState = await types.readDaemonState();
5888
- if (daemonState && daemonState.pid !== process.pid) {
5889
- types.logger.debug("[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves.");
5890
- requestShutdown("exception", "A different daemon was started without killing us. We should kill ourselves.");
5891
- }
5892
- try {
5893
- const updatedState = {
5894
- pid: process.pid,
5895
- httpPort: controlPort,
5896
- startTime: fileState.startTime,
5897
- startedWithCliVersion: types.packageJson.version,
5898
- lastHeartbeat: (/* @__PURE__ */ new Date()).toLocaleString(),
5899
- daemonLogPath: fileState.daemonLogPath
5900
- };
5901
- types.writeDaemonState(updatedState);
5902
- if (process.env.DEBUG) {
5903
- types.logger.debug(`[DAEMON RUN] Health check completed at ${updatedState.lastHeartbeat}`);
6237
+ types.logger.debug("[DAEMON RUN] Failed to write heartbeat", error);
5904
6238
  }
5905
- } catch (error) {
5906
- types.logger.debug("[DAEMON RUN] Failed to write heartbeat", error);
6239
+ } finally {
6240
+ heartbeatRunning = false;
5907
6241
  }
5908
- heartbeatRunning = false;
5909
6242
  }, heartbeatIntervalMs);
5910
6243
  const cleanupAndShutdown = async (source, errorMessage) => {
5911
6244
  types.logger.debug(`[DAEMON RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`);
@@ -5914,13 +6247,15 @@ Fix: restart the daemon and try again (macOS: \`flockbay daemon stop\` then \`fl
5914
6247
  clearInterval(restartOnStaleVersionAndHeartbeat);
5915
6248
  types.logger.debug("[DAEMON RUN] Health check interval cleared");
5916
6249
  }
5917
- await apiMachine.updateDaemonState((state) => ({
5918
- ...state,
5919
- status: "shutting-down",
5920
- shutdownRequestedAt: Date.now(),
5921
- shutdownSource: source
5922
- }));
5923
- await new Promise((resolve) => setTimeout(resolve, 100));
6250
+ try {
6251
+ await apiMachine?.updateDaemonStateOnce?.((state) => ({
6252
+ ...state,
6253
+ status: "shutting-down",
6254
+ shutdownRequestedAt: Date.now(),
6255
+ shutdownSource: source
6256
+ }));
6257
+ } catch {
6258
+ }
5924
6259
  if (killSessionsOnShutdown) {
5925
6260
  try {
5926
6261
  const tracked = Array.from(pidToTrackedSession.entries());
@@ -5971,7 +6306,7 @@ Fix: restart the daemon and try again (macOS: \`flockbay daemon stop\` then \`fl
5971
6306
  } else {
5972
6307
  types.logger.debug("[DAEMON RUN] Preserving session processes across daemon shutdown");
5973
6308
  }
5974
- apiMachine.shutdown();
6309
+ apiMachine?.shutdown();
5975
6310
  try {
5976
6311
  unrealMcpChild?.kill();
5977
6312
  } catch {
@@ -5986,7 +6321,12 @@ Fix: restart the daemon and try again (macOS: \`flockbay daemon stop\` then \`fl
5986
6321
  process.exit(0);
5987
6322
  };
5988
6323
  types.logger.debug("[DAEMON RUN] Daemon started successfully, waiting for shutdown request");
6324
+ startupCompleted = true;
5989
6325
  const shutdownRequest = await resolvesWhenShutdownRequested;
6326
+ if (startupForceExitTimer) {
6327
+ clearTimeout(startupForceExitTimer);
6328
+ startupForceExitTimer = null;
6329
+ }
5990
6330
  await cleanupAndShutdown(shutdownRequest.source, shutdownRequest.errorMessage);
5991
6331
  } catch (error) {
5992
6332
  types.logger.debug("[DAEMON RUN][FATAL] Failed somewhere unexpectedly - exiting with code 1", error);
@@ -6029,12 +6369,259 @@ Fix: restart the daemon and try again (macOS: \`flockbay daemon stop\` then \`fl
6029
6369
  async function runMechanicRun(_input) {
6030
6370
  return {
6031
6371
  ok: false,
6032
- errorMessage: "Mechanic runs are disabled (legacy Unreal project plugin removed).",
6372
+ errorMessage: "Mechanic runs are disabled.",
6033
6373
  hint: "Remove reliance on `unreal_mechanic_run`, or re-introduce this tool as a future feature built on project-local tooling.",
6034
6374
  artifactsDir: null
6035
6375
  };
6036
6376
  }
6037
6377
 
6378
+ function targetPlatform() {
6379
+ if (process.platform === "win32") return "Win64";
6380
+ if (process.platform === "darwin") return "Mac";
6381
+ return "Linux";
6382
+ }
6383
+ function buildScriptPath(engineRoot) {
6384
+ if (process.platform === "win32") {
6385
+ return path.join(engineRoot, "Engine", "Build", "BatchFiles", "Build.bat");
6386
+ }
6387
+ return path.join(engineRoot, "Engine", "Build", "BatchFiles", "Build.sh");
6388
+ }
6389
+ function parseBuildIssuesFromText(text, limit) {
6390
+ const issues = [];
6391
+ let errors = 0;
6392
+ let warnings = 0;
6393
+ let truncated = false;
6394
+ const clangRe = /^(.+?):(\d+):(\d+):\s+(warning|error):\s+(.*)$/;
6395
+ const msvcRe = /^(.+?)\((\d+)\):\s+(warning|error)\s+([A-Z]+\d+):\s+(.*)$/;
6396
+ const lines = String(text ?? "").split(/\r?\n/);
6397
+ for (const line of lines) {
6398
+ let m = clangRe.exec(line);
6399
+ if (m) {
6400
+ const severity = m[4] === "warning" ? "warning" : "error";
6401
+ if (severity === "error") errors += 1;
6402
+ else warnings += 1;
6403
+ if (issues.length < limit) {
6404
+ issues.push({
6405
+ file: m[1] ? String(m[1]) : null,
6406
+ line: Number(m[2]) || null,
6407
+ column: Number(m[3]) || null,
6408
+ severity,
6409
+ code: null,
6410
+ message: String(m[5] ?? "").trim()
6411
+ });
6412
+ } else {
6413
+ truncated = true;
6414
+ }
6415
+ continue;
6416
+ }
6417
+ m = msvcRe.exec(line);
6418
+ if (m) {
6419
+ const severity = m[3] === "warning" ? "warning" : "error";
6420
+ if (severity === "error") errors += 1;
6421
+ else warnings += 1;
6422
+ if (issues.length < limit) {
6423
+ issues.push({
6424
+ file: m[1] ? String(m[1]) : null,
6425
+ line: Number(m[2]) || null,
6426
+ column: null,
6427
+ severity,
6428
+ code: m[4] ? String(m[4]) : null,
6429
+ message: String(m[5] ?? "").trim()
6430
+ });
6431
+ } else {
6432
+ truncated = true;
6433
+ }
6434
+ continue;
6435
+ }
6436
+ }
6437
+ return { issues, errors, warnings, truncated };
6438
+ }
6439
+ async function buildUnrealProject(args) {
6440
+ const uprojectPath = args.uprojectPath;
6441
+ const engineRoot = args.engineRoot;
6442
+ const configuration = args.configuration ?? "Development";
6443
+ const target = args.target ?? "Editor";
6444
+ const timeoutMs = Math.max(3e4, args.timeoutMs ?? 20 * 6e4);
6445
+ const issuesLimit = Math.max(1, Math.min(2e3, args.issuesLimit ?? 250));
6446
+ if (!uprojectPath || !path.isAbsolute(uprojectPath) || !uprojectPath.toLowerCase().endsWith(".uproject")) {
6447
+ return {
6448
+ ok: false,
6449
+ exitCode: null,
6450
+ logPath: "",
6451
+ issues: [],
6452
+ errors: 0,
6453
+ warnings: 0,
6454
+ truncated: false,
6455
+ errorMessage: `Invalid uprojectPath (must be an absolute path to *.uproject): ${String(uprojectPath)}`
6456
+ };
6457
+ }
6458
+ if (!fs.existsSync(uprojectPath)) {
6459
+ return {
6460
+ ok: false,
6461
+ exitCode: null,
6462
+ logPath: "",
6463
+ issues: [],
6464
+ errors: 0,
6465
+ warnings: 0,
6466
+ truncated: false,
6467
+ errorMessage: `uprojectPath not found: ${uprojectPath}`
6468
+ };
6469
+ }
6470
+ const script = buildScriptPath(engineRoot);
6471
+ if (!fs.existsSync(script)) {
6472
+ return {
6473
+ ok: false,
6474
+ exitCode: null,
6475
+ logPath: "",
6476
+ issues: [],
6477
+ errors: 0,
6478
+ warnings: 0,
6479
+ truncated: false,
6480
+ errorMessage: `Missing Unreal build script: ${script}`
6481
+ };
6482
+ }
6483
+ const projectName = path.basename(uprojectPath).replace(/\.uproject$/i, "");
6484
+ const targetName = target === "Editor" ? `${projectName}Editor` : projectName;
6485
+ const platform = targetPlatform();
6486
+ const logDir = args.logDir ?? path.join(path.dirname(uprojectPath), "Saved", "Logs", "Flockbay");
6487
+ fs.mkdirSync(logDir, { recursive: true });
6488
+ const logPath = path.join(logDir, `flockbay_build_${Date.now()}.log`);
6489
+ const buildArgs = [
6490
+ targetName,
6491
+ platform,
6492
+ configuration,
6493
+ `-Project=${uprojectPath}`,
6494
+ "-WaitMutex",
6495
+ "-NoHotReload"
6496
+ ];
6497
+ const logStream = fs.createWriteStream(logPath, { flags: "a" });
6498
+ logStream.write(`engineRoot: ${engineRoot}
6499
+ `);
6500
+ logStream.write(`uprojectPath: ${uprojectPath}
6501
+ `);
6502
+ logStream.write(`script: ${script}
6503
+ `);
6504
+ logStream.write(`args: ${JSON.stringify(buildArgs)}
6505
+
6506
+ `);
6507
+ const child = process.platform === "win32" ? node_child_process.spawn("cmd.exe", ["/c", script, ...buildArgs], { stdio: ["ignore", "pipe", "pipe"] }) : node_child_process.spawn(script, buildArgs, { stdio: ["ignore", "pipe", "pipe"] });
6508
+ let combinedTail = "";
6509
+ const appendTail = (chunk) => {
6510
+ combinedTail += chunk.toString("utf8");
6511
+ if (combinedTail.length > 2e6) combinedTail = combinedTail.slice(-4e5);
6512
+ };
6513
+ child.stdout?.on("data", (chunk) => {
6514
+ logStream.write(chunk);
6515
+ appendTail(chunk);
6516
+ });
6517
+ child.stderr?.on("data", (chunk) => {
6518
+ logStream.write(chunk);
6519
+ appendTail(chunk);
6520
+ });
6521
+ const exitCode = await new Promise((resolve) => {
6522
+ const timer = setTimeout(() => {
6523
+ try {
6524
+ child.kill("SIGKILL");
6525
+ } catch {
6526
+ }
6527
+ resolve(null);
6528
+ }, timeoutMs);
6529
+ child.on("close", (code) => {
6530
+ clearTimeout(timer);
6531
+ resolve(code);
6532
+ });
6533
+ child.on("error", () => {
6534
+ clearTimeout(timer);
6535
+ resolve(null);
6536
+ });
6537
+ });
6538
+ logStream.end(`
6539
+ exitCode: ${exitCode}
6540
+ `);
6541
+ const parsed = parseBuildIssuesFromText(combinedTail, issuesLimit);
6542
+ const ok = exitCode === 0;
6543
+ if (!ok) {
6544
+ return {
6545
+ ok: false,
6546
+ exitCode,
6547
+ logPath,
6548
+ ...parsed,
6549
+ errorMessage: exitCode === null ? `Build timed out after ${timeoutMs}ms. Log: ${logPath}` : `Build failed (exitCode=${exitCode}). Log: ${logPath}`
6550
+ };
6551
+ }
6552
+ return {
6553
+ ok: true,
6554
+ exitCode: exitCode ?? 0,
6555
+ logPath,
6556
+ ...parsed
6557
+ };
6558
+ }
6559
+
6560
+ function stampForFilename() {
6561
+ const d = /* @__PURE__ */ new Date();
6562
+ const pad = (n) => String(n).padStart(2, "0");
6563
+ const yyyy = d.getFullYear();
6564
+ const mm = pad(d.getMonth() + 1);
6565
+ const dd = pad(d.getDate());
6566
+ const hh = pad(d.getHours());
6567
+ const mi = pad(d.getMinutes());
6568
+ const ss = pad(d.getSeconds());
6569
+ return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
6570
+ }
6571
+ async function runUnrealSmokeTest(args) {
6572
+ const startedAtMs = Date.now();
6573
+ const timeoutMs = Math.max(5e3, args.timeoutMs ?? 3e4);
6574
+ const stabilizeMs = Math.max(250, args.stabilizeMs ?? 1500);
6575
+ const stopIfPlaying = args.stopIfPlaying ?? true;
6576
+ const screenshotDir = path.join(path.dirname(args.uprojectPath), "Saved", "Screenshots", "Flockbay");
6577
+ await fs$2.mkdir(screenshotDir, { recursive: true });
6578
+ const screenshotPath = path.join(screenshotDir, `Flockbay_smoke_test_${stampForFilename()}.png`);
6579
+ let started = false;
6580
+ let stopped = false;
6581
+ try {
6582
+ const status = await types.sendUnrealMcpTcpCommand({ type: "get_play_in_editor_status", params: {}, timeoutMs: Math.min(timeoutMs, 5e3) });
6583
+ const result = status?.result && typeof status.result === "object" ? status.result : status;
6584
+ const isPlaying = typeof result?.isPlaying === "boolean" ? result.isPlaying : null;
6585
+ if (isPlaying) {
6586
+ if (!stopIfPlaying) {
6587
+ return {
6588
+ ok: false,
6589
+ durationMs: Date.now() - startedAtMs,
6590
+ started: false,
6591
+ stopped: false,
6592
+ screenshotPath: null,
6593
+ errorMessage: "PIE is currently running. Stop PIE first or re-run with stopIfPlaying=true."
6594
+ };
6595
+ }
6596
+ await types.sendUnrealMcpTcpCommand({ type: "stop_play_in_editor", params: {}, timeoutMs: Math.min(timeoutMs, 1e4) });
6597
+ }
6598
+ const playRes = await types.sendUnrealMcpTcpCommand({ type: "play_in_editor_windowed", params: {}, timeoutMs: Math.min(timeoutMs, 1e4) });
6599
+ const playResult = playRes?.result && typeof playRes.result === "object" ? playRes.result : playRes;
6600
+ started = typeof playResult?.started === "boolean" ? playResult.started : true;
6601
+ await new Promise((r) => setTimeout(r, stabilizeMs));
6602
+ await types.sendUnrealMcpTcpCommand({ type: "take_screenshot", params: { filepath: screenshotPath }, timeoutMs: Math.min(timeoutMs, 2e4) });
6603
+ await types.sendUnrealMcpTcpCommand({ type: "stop_play_in_editor", params: {}, timeoutMs: Math.min(timeoutMs, 1e4) });
6604
+ stopped = true;
6605
+ return {
6606
+ ok: true,
6607
+ durationMs: Date.now() - startedAtMs,
6608
+ started,
6609
+ stopped,
6610
+ screenshotPath
6611
+ };
6612
+ } catch (err) {
6613
+ const message = err instanceof Error ? err.message : String(err);
6614
+ return {
6615
+ ok: false,
6616
+ durationMs: Date.now() - startedAtMs,
6617
+ started,
6618
+ stopped,
6619
+ screenshotPath: screenshotPath || null,
6620
+ errorMessage: message
6621
+ };
6622
+ }
6623
+ }
6624
+
6038
6625
  function safeJsonParse(value) {
6039
6626
  try {
6040
6627
  return JSON.parse(value);
@@ -6251,12 +6838,172 @@ class ElicitationHub {
6251
6838
  }
6252
6839
  }
6253
6840
 
6841
+ function encodeBase64(buffer, variant = "base64") {
6842
+ if (variant === "base64url") {
6843
+ return encodeBase64Url(buffer);
6844
+ }
6845
+ return Buffer.from(buffer).toString("base64");
6846
+ }
6847
+ function encodeBase64Url(buffer) {
6848
+ return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
6849
+ }
6850
+ function getRandomBytes(size) {
6851
+ return new Uint8Array(node_crypto.randomBytes(size));
6852
+ }
6853
+ function encryptWithDataKey(data, dataKey) {
6854
+ const nonce = getRandomBytes(12);
6855
+ const cipher = node_crypto.createCipheriv("aes-256-gcm", dataKey, nonce);
6856
+ const plaintext = new TextEncoder().encode(JSON.stringify(data));
6857
+ const encrypted = Buffer.concat([
6858
+ cipher.update(plaintext),
6859
+ cipher.final()
6860
+ ]);
6861
+ const authTag = cipher.getAuthTag();
6862
+ const bundle = new Uint8Array(12 + encrypted.length + 16 + 1);
6863
+ bundle.set([0], 0);
6864
+ bundle.set(nonce, 1);
6865
+ bundle.set(new Uint8Array(encrypted), 13);
6866
+ bundle.set(new Uint8Array(authTag), 13 + encrypted.length);
6867
+ return bundle;
6868
+ }
6869
+ function encrypt(key, data) {
6870
+ return encryptWithDataKey(data, key);
6871
+ }
6872
+
6254
6873
  function deriveScreenshotViewIdFromFilename(name) {
6255
6874
  const base = name.replace(/\.[^.]+$/, "");
6256
6875
  const prefixed = /^Flockbay_(.+)$/.exec(base);
6257
6876
  const raw = (prefixed ? prefixed[1] : base).trim();
6258
6877
  return raw.replace(/_\d{8}-\d{6}$/, "").trim() || base;
6259
6878
  }
6879
+ async function readJsonFile(filePath) {
6880
+ const raw = await fs$2.readFile(filePath, "utf8");
6881
+ return JSON.parse(raw);
6882
+ }
6883
+ function parseMajorMinorOrNull(raw) {
6884
+ if (typeof raw !== "string") return null;
6885
+ const m = raw.trim().match(/(\d+)\.(\d+)/);
6886
+ if (!m) return null;
6887
+ const major = Number(m[1]);
6888
+ const minor = Number(m[2]);
6889
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
6890
+ return { major, minor };
6891
+ }
6892
+ async function readUprojectEngineAssociationOrNull(uprojectPath) {
6893
+ try {
6894
+ const json = await readJsonFile(uprojectPath);
6895
+ return parseMajorMinorOrNull(json?.EngineAssociation);
6896
+ } catch {
6897
+ return null;
6898
+ }
6899
+ }
6900
+ function isValidEngineRoot(engineRoot) {
6901
+ if (!engineRoot) return false;
6902
+ if (!fs.existsSync(engineRoot)) return false;
6903
+ if (!fs.existsSync(path.join(engineRoot, "Engine"))) return false;
6904
+ const buildVersion = path.join(engineRoot, "Engine", "Build", "Build.version");
6905
+ if (fs.existsSync(buildVersion)) return true;
6906
+ const editorCmd = process.platform === "darwin" ? path.join(engineRoot, "Engine", "Binaries", "Mac", "UnrealEditor-Cmd") : process.platform === "win32" ? path.join(engineRoot, "Engine", "Binaries", "Win64", "UnrealEditor-Cmd.exe") : process.platform === "linux" ? path.join(engineRoot, "Engine", "Binaries", "Linux", "UnrealEditor-Cmd") : "";
6907
+ if (editorCmd && fs.existsSync(editorCmd)) return true;
6908
+ return false;
6909
+ }
6910
+ function engineRootCandidatesForVersion(platform, version) {
6911
+ const suffix = `${version.major}.${version.minor}`;
6912
+ if (platform === "darwin") {
6913
+ return [
6914
+ `/Users/Shared/Epic Games/UE_${suffix}`,
6915
+ `/Applications/Epic Games/UE_${suffix}`,
6916
+ `/Applications/Unreal Engine/UE_${suffix}`
6917
+ ];
6918
+ }
6919
+ if (platform === "win32") {
6920
+ return [
6921
+ `C:\\\\Program Files\\\\Epic Games\\\\UE_${suffix}`
6922
+ ];
6923
+ }
6924
+ return [];
6925
+ }
6926
+ async function readEngineRootMajorMinorOrNull(engineRoot) {
6927
+ const buildVersionPath = path.join(engineRoot, "Engine", "Build", "Build.version");
6928
+ try {
6929
+ if (fs.existsSync(buildVersionPath)) {
6930
+ const json = await readJsonFile(buildVersionPath);
6931
+ const major2 = Number(json?.MajorVersion);
6932
+ const minor2 = Number(json?.MinorVersion);
6933
+ if (Number.isFinite(major2) && Number.isFinite(minor2)) return { major: major2, minor: minor2 };
6934
+ }
6935
+ } catch {
6936
+ }
6937
+ const m = engineRoot.match(/UE_(\d+)\.(\d+)/);
6938
+ if (!m) return null;
6939
+ const major = Number(m[1]);
6940
+ const minor = Number(m[2]);
6941
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
6942
+ return { major, minor };
6943
+ }
6944
+ async function assertEngineRootMatchesUprojectOrThrow(args) {
6945
+ const engineAssociation = await readUprojectEngineAssociationOrNull(args.uprojectPath);
6946
+ if (!engineAssociation) return;
6947
+ const engineRootVersion = await readEngineRootMajorMinorOrNull(args.engineRoot);
6948
+ if (!engineRootVersion) return;
6949
+ if (engineRootVersion.major !== engineAssociation.major || engineRootVersion.minor !== engineAssociation.minor) {
6950
+ const candidates = engineRootCandidatesForVersion(process.platform, engineAssociation);
6951
+ const suggestedEngineRoot = candidates.find((c) => isValidEngineRoot(c)) || candidates[0] || null;
6952
+ throw new Error(
6953
+ [
6954
+ `Engine mismatch: this project targets UE ${engineAssociation.major}.${engineAssociation.minor} (EngineAssociation),`,
6955
+ `but the provided engineRoot is UE ${engineRootVersion.major}.${engineRootVersion.minor}: ${args.engineRoot}`,
6956
+ "",
6957
+ args.source === "env" ? `Fix: update ENGINE_ROOT to a matching UE install (or unset it and let the tool infer from the .uproject).` : `Fix: pass a matching engineRoot (or omit it and let the tool infer from the .uproject).`,
6958
+ ...suggestedEngineRoot ? [`Example engineRoot: ${suggestedEngineRoot}`] : []
6959
+ ].join("\n")
6960
+ );
6961
+ }
6962
+ }
6963
+ async function resolveEngineRootForUproject(args) {
6964
+ const fromArg = (args.engineRootArg || "").trim();
6965
+ if (fromArg) {
6966
+ if (!isValidEngineRoot(fromArg)) {
6967
+ return { ok: false, errorMessage: `Invalid engineRoot (expected a UE install root containing Engine/\u2026): ${fromArg}`, suggestedEngineRoot: null };
6968
+ }
6969
+ try {
6970
+ await assertEngineRootMatchesUprojectOrThrow({ uprojectPath: args.uprojectPath, engineRoot: fromArg, source: "arg" });
6971
+ } catch (e) {
6972
+ return { ok: false, errorMessage: e instanceof Error ? e.message : String(e), suggestedEngineRoot: null };
6973
+ }
6974
+ return { ok: true, engineRoot: fromArg, source: "arg", engineAssociation: await readUprojectEngineAssociationOrNull(args.uprojectPath) };
6975
+ }
6976
+ const fromEnv = (process.env.UE_ENGINE_ROOT || process.env.ENGINE_ROOT || "").trim();
6977
+ if (fromEnv) {
6978
+ if (!isValidEngineRoot(fromEnv)) {
6979
+ return { ok: false, errorMessage: `ENGINE_ROOT is set but invalid (expected a UE install root containing Engine/\u2026): ${fromEnv}`, suggestedEngineRoot: null };
6980
+ }
6981
+ try {
6982
+ await assertEngineRootMatchesUprojectOrThrow({ uprojectPath: args.uprojectPath, engineRoot: fromEnv, source: "env" });
6983
+ } catch (e) {
6984
+ return { ok: false, errorMessage: e instanceof Error ? e.message : String(e), suggestedEngineRoot: null };
6985
+ }
6986
+ return { ok: true, engineRoot: fromEnv, source: "env", engineAssociation: await readUprojectEngineAssociationOrNull(args.uprojectPath) };
6987
+ }
6988
+ const engineAssociation = await readUprojectEngineAssociationOrNull(args.uprojectPath);
6989
+ if (!engineAssociation) {
6990
+ return {
6991
+ ok: false,
6992
+ errorMessage: "Missing `engineRoot` (and UE_ENGINE_ROOT / ENGINE_ROOT not set). Unable to infer the engine version from the .uproject EngineAssociation.",
6993
+ suggestedEngineRoot: null
6994
+ };
6995
+ }
6996
+ const candidates = engineRootCandidatesForVersion(process.platform, engineAssociation);
6997
+ const valid = candidates.filter((c) => isValidEngineRoot(c));
6998
+ if (valid.length > 0) {
6999
+ return { ok: true, engineRoot: valid[0], source: "uproject", engineAssociation };
7000
+ }
7001
+ return {
7002
+ ok: false,
7003
+ errorMessage: `Missing \`engineRoot\` (and UE_ENGINE_ROOT / ENGINE_ROOT not set). This project targets UE ${engineAssociation.major}.${engineAssociation.minor} but no standard engine install was found.`,
7004
+ suggestedEngineRoot: candidates[0] || null
7005
+ };
7006
+ }
6260
7007
  async function runCmdAndCapture(args) {
6261
7008
  return await new Promise((resolvePromise, rejectPromise) => {
6262
7009
  const child = node_child_process.spawn(args.cmd, args.cmdArgs, { stdio: ["ignore", "pipe", "pipe"], cwd: args.cwd, env: args.env });
@@ -6302,7 +7049,9 @@ async function uploadScreenshotViewsForSession(args) {
6302
7049
  const res = await fetch(endpoint, {
6303
7050
  method: "POST",
6304
7051
  headers: {
6305
- Authorization: `Bearer ${args.token}`
7052
+ // This tool runs inside the CLI/daemon context, so we authenticate as the machine.
7053
+ // The backend accepts `Machine <token>` for machine-scoped auth.
7054
+ Authorization: `Machine ${args.token}`
6306
7055
  },
6307
7056
  body: form
6308
7057
  });
@@ -6338,11 +7087,14 @@ async function startFlockbayServer(client, options) {
6338
7087
  const handler = async (title) => {
6339
7088
  types.logger.debug("[flockbayMCP] Changing title to:", title);
6340
7089
  try {
6341
- client.sendClaudeSessionMessage({
6342
- type: "summary",
6343
- summary: title,
6344
- leafUuid: node_crypto.randomUUID()
6345
- });
7090
+ const trimmed = String(title || "").trim();
7091
+ if (!trimmed) {
7092
+ return { success: false, error: "Missing title" };
7093
+ }
7094
+ client.updateMetadata((current) => ({
7095
+ ...current || {},
7096
+ name: trimmed
7097
+ }));
6346
7098
  return { success: true };
6347
7099
  } catch (error) {
6348
7100
  return { success: false, error: String(error) };
@@ -6371,7 +7123,7 @@ async function startFlockbayServer(client, options) {
6371
7123
  const res = await fetch(`${types.configuration.serverUrl.replace(/\/+$/, "")}${pathname}`, {
6372
7124
  method: "POST",
6373
7125
  headers: {
6374
- Authorization: `Bearer ${client.getAuthToken()}`,
7126
+ Authorization: `Machine ${client.getAuthToken()}`,
6375
7127
  "Content-Type": "application/json"
6376
7128
  },
6377
7129
  body: JSON.stringify(body ?? {})
@@ -6396,6 +7148,166 @@ async function startFlockbayServer(client, options) {
6396
7148
  const meta = client?.metadata;
6397
7149
  return String(meta?.path || "").trim() || process.cwd();
6398
7150
  };
7151
+ const unrealIssueEmitter = new node_events.EventEmitter();
7152
+ const unrealEditorSupervisor = (() => {
7153
+ const state = {
7154
+ lastActivityAtMs: 0,
7155
+ lastReachableAtMs: 0,
7156
+ lastIssueAtMs: 0,
7157
+ lastIssueKey: "",
7158
+ launched: null
7159
+ };
7160
+ const emitIssue = (event) => {
7161
+ const key = `${event.kind}:${event.severity}:${event.message}`;
7162
+ const now = event.detectedAtMs;
7163
+ if (state.lastIssueKey === key && now - state.lastIssueAtMs < 15e3) return;
7164
+ state.lastIssueKey = key;
7165
+ state.lastIssueAtMs = now;
7166
+ unrealIssueEmitter.emit("issue", event);
7167
+ if (unrealIssueEmitter.listenerCount("issue") === 0) {
7168
+ try {
7169
+ const msg = event.kind === "process_exit" ? `Unreal Editor crashed. ${event.message}` : `Unreal Editor is not reachable. ${event.message}`;
7170
+ client.sendSessionEvent({ type: "message", message: msg });
7171
+ const socket = client?.socket;
7172
+ const keyBytes = client?.encryptionKey;
7173
+ if (socket && keyBytes) {
7174
+ const params = encodeBase64(encrypt(keyBytes, {}));
7175
+ socket.emit("rpc-call", { method: `${client.sessionId}:abort`, params }, () => {
7176
+ });
7177
+ }
7178
+ } catch (err) {
7179
+ types.logger.debug("[flockbayMCP] Failed to auto-abort after Unreal issue", err);
7180
+ }
7181
+ }
7182
+ };
7183
+ const getUnrealEditorExe = (engineRoot) => {
7184
+ const root = engineRoot.trim().replace(/[\\/]+$/, "");
7185
+ if (process.platform === "darwin") {
7186
+ return path.join(root, "Engine", "Binaries", "Mac", "UnrealEditor.app", "Contents", "MacOS", "UnrealEditor");
7187
+ }
7188
+ if (process.platform === "win32") {
7189
+ return path.join(root, "Engine", "Binaries", "Win64", "UnrealEditor.exe");
7190
+ }
7191
+ if (process.platform === "linux") {
7192
+ return path.join(root, "Engine", "Binaries", "Linux", "UnrealEditor");
7193
+ }
7194
+ return "";
7195
+ };
7196
+ const runCommandCapture = async (cmd, args) => {
7197
+ return new Promise((resolve) => {
7198
+ const child = node_child_process.spawn(cmd, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
7199
+ let stdout = "";
7200
+ let stderr = "";
7201
+ child.stdout?.on("data", (c) => stdout += c.toString("utf8"));
7202
+ child.stderr?.on("data", (c) => stderr += c.toString("utf8"));
7203
+ child.on("error", (e) => resolve({ ok: false, stdout, stderr: e instanceof Error ? e.message : String(e) }));
7204
+ child.on("close", (code) => resolve({ ok: code === 0, stdout, stderr }));
7205
+ });
7206
+ };
7207
+ const isUnrealEditorProcessRunningBestEffort = async () => {
7208
+ if (process.platform === "win32") {
7209
+ const res2 = await runCommandCapture("tasklist", ["/FI", "IMAGENAME eq UnrealEditor.exe", "/FO", "CSV", "/NH"]);
7210
+ const out2 = `${res2.stdout}
7211
+ ${res2.stderr}`.toLowerCase();
7212
+ return out2.includes("unrealeditor.exe");
7213
+ }
7214
+ const res = await runCommandCapture("ps", ["-A", "-o", "comm="]);
7215
+ const out = `${res.stdout}
7216
+ ${res.stderr}`;
7217
+ return /\bUnrealEditor\b/.test(out) || /\bUE4Editor\b/.test(out) || /\bUE5Editor\b/.test(out);
7218
+ };
7219
+ const tick = async () => {
7220
+ const now = Date.now();
7221
+ const activeWindowMs = 10 * 6e4;
7222
+ const isActive = state.lastActivityAtMs > 0 && now - state.lastActivityAtMs < activeWindowMs || Boolean(state.launched);
7223
+ if (!isActive) return;
7224
+ try {
7225
+ await types.sendUnrealMcpTcpCommand({ type: "ping", params: {}, timeoutMs: 750 });
7226
+ state.lastReachableAtMs = now;
7227
+ return;
7228
+ } catch {
7229
+ }
7230
+ if (state.launched) return;
7231
+ if (!state.lastReachableAtMs || now - state.lastReachableAtMs > activeWindowMs) return;
7232
+ const running = await isUnrealEditorProcessRunningBestEffort().catch(() => true);
7233
+ if (running) return;
7234
+ emitIssue({
7235
+ kind: "unreachable",
7236
+ severity: "warning",
7237
+ detectedAtMs: now,
7238
+ message: "Unreal Editor is no longer reachable (it was reachable earlier). It may have crashed or been closed.",
7239
+ detail: {
7240
+ lastReachableAtMs: state.lastReachableAtMs
7241
+ }
7242
+ });
7243
+ };
7244
+ const interval = setInterval(() => {
7245
+ void tick();
7246
+ }, 2e3);
7247
+ interval.unref();
7248
+ const noteUnrealActivity = () => {
7249
+ state.lastActivityAtMs = Date.now();
7250
+ };
7251
+ const noteUnrealReachable = () => {
7252
+ state.lastReachableAtMs = Date.now();
7253
+ };
7254
+ const launchEditor = async (params) => {
7255
+ const uprojectPath = params.uprojectPath;
7256
+ const engineRoot = params.engineRoot;
7257
+ const extraArgs = Array.isArray(params.extraArgs) ? params.extraArgs.filter((a) => typeof a === "string" && a.trim()) : [];
7258
+ const exe = getUnrealEditorExe(engineRoot);
7259
+ if (!exe) throw new Error(`Unsupported platform for Unreal Editor launch: ${process.platform}`);
7260
+ if (!fs.existsSync(exe)) throw new Error(`Unreal Editor binary not found: ${exe}`);
7261
+ const child = node_child_process.spawn(exe, [uprojectPath, ...extraArgs], { detached: true, stdio: "ignore" });
7262
+ child.unref();
7263
+ const pid = typeof child.pid === "number" ? child.pid : 0;
7264
+ state.launched = {
7265
+ pid,
7266
+ uprojectPath,
7267
+ engineRoot,
7268
+ startedAtMs: Date.now()
7269
+ };
7270
+ child.on("exit", (code, signal) => {
7271
+ const now = Date.now();
7272
+ const exitCode = typeof code === "number" ? code : null;
7273
+ const sig = typeof signal === "string" ? signal : null;
7274
+ const isCrash = sig !== null || exitCode !== null && exitCode !== 0;
7275
+ state.launched = null;
7276
+ if (!isCrash) return;
7277
+ emitIssue({
7278
+ kind: "process_exit",
7279
+ severity: "crash",
7280
+ detectedAtMs: now,
7281
+ message: `Unreal Editor process exited unexpectedly (code=${exitCode ?? "null"} signal=${sig ?? "null"}).`,
7282
+ detail: { exitCode, signal: sig, pid, uprojectPath }
7283
+ });
7284
+ });
7285
+ child.on("error", (err) => {
7286
+ const now = Date.now();
7287
+ state.launched = null;
7288
+ emitIssue({
7289
+ kind: "process_exit",
7290
+ severity: "crash",
7291
+ detectedAtMs: now,
7292
+ message: `Failed to launch Unreal Editor: ${err instanceof Error ? err.message : String(err)}`,
7293
+ detail: { pid, uprojectPath }
7294
+ });
7295
+ });
7296
+ return { pid, exePath: exe };
7297
+ };
7298
+ return {
7299
+ noteUnrealActivity,
7300
+ noteUnrealReachable,
7301
+ launchEditor,
7302
+ stop: () => {
7303
+ try {
7304
+ interval.unref();
7305
+ clearInterval(interval);
7306
+ } catch {
7307
+ }
7308
+ }
7309
+ };
7310
+ })();
6399
7311
  const runGit = async (cmdArgs, cwd, timeoutMs) => {
6400
7312
  const res = await runCmdAndCapture({
6401
7313
  cmd: "git",
@@ -7103,7 +8015,7 @@ ${String(st.stdout || "").trim()}`
7103
8015
  const url = `${types.configuration.serverUrl.replace(/\/+$/, "")}/v1/sessions/${encodeURIComponent(client.sessionId)}/evidence-artifacts?limit=${limit}`;
7104
8016
  const res = await fetch(url, {
7105
8017
  method: "GET",
7106
- headers: { Authorization: `Bearer ${client.getAuthToken()}`, accept: "application/json" }
8018
+ headers: { Authorization: `Machine ${client.getAuthToken()}`, accept: "application/json" }
7107
8019
  });
7108
8020
  const data = await res.json().catch(() => null);
7109
8021
  if (!res.ok) {
@@ -7128,7 +8040,7 @@ ${String(st.stdout || "").trim()}`
7128
8040
  const url = `${types.configuration.serverUrl.replace(/\/+$/, "")}/v1/sessions/${encodeURIComponent(client.sessionId)}/evidence-artifacts/${encodeURIComponent(id)}`;
7129
8041
  const res = await fetch(url, {
7130
8042
  method: "GET",
7131
- headers: { Authorization: `Bearer ${client.getAuthToken()}`, accept: "application/json" }
8043
+ headers: { Authorization: `Machine ${client.getAuthToken()}`, accept: "application/json" }
7132
8044
  });
7133
8045
  const data = await res.json().catch(() => null);
7134
8046
  if (!res.ok) {
@@ -7684,6 +8596,7 @@ ${String(st.stdout || "").trim()}`
7684
8596
  }
7685
8597
  return {
7686
8598
  content,
8599
+ views: viewsPayload,
7687
8600
  isError: false
7688
8601
  };
7689
8602
  } catch (error) {
@@ -7698,8 +8611,8 @@ ${String(st.stdout || "").trim()}`
7698
8611
  })
7699
8612
  );
7700
8613
  mcp.registerTool("unreal_latest_screenshots", {
7701
- title: "Latest Unreal Screenshots",
7702
- description: "Fetch the latest PNG screenshots from `Saved/Screenshots/Flockbay/` and return a `{ views: [...] }` payload so the app can display them.",
8614
+ title: "Latest Unreal Screenshots (Validation)",
8615
+ description: "Fetch the latest PNG screenshots from `Saved/Screenshots/Flockbay/` (for validation) and return a `{ views: [...] }` payload so the app can display them.",
7703
8616
  inputSchema: {
7704
8617
  uprojectPath: z.z.string().describe("Absolute path to the .uproject file."),
7705
8618
  limit: z.z.number().int().positive().optional().describe("Max number of screenshots to return (default 12)."),
@@ -7784,6 +8697,7 @@ ${String(st.stdout || "").trim()}`
7784
8697
  { type: "text", text: `Found ${views.length} screenshot${views.length === 1 ? "" : "s"} in: ${outDir}` },
7785
8698
  { type: "text", text: JSON.stringify({ views }, null, 2) }
7786
8699
  ],
8700
+ views,
7787
8701
  isError: false
7788
8702
  };
7789
8703
  } catch (error) {
@@ -7819,6 +8733,7 @@ ${String(st.stdout || "").trim()}`
7819
8733
  const name = typeof pluginInfo?.name === "string" ? pluginInfo.name : "UnrealMCP";
7820
8734
  const versionName = typeof pluginInfo?.versionName === "string" ? pluginInfo.versionName : null;
7821
8735
  const baseDir = typeof pluginInfo?.baseDir === "string" ? pluginInfo.baseDir : null;
8736
+ const schemaVersion = typeof pluginInfo?.schemaVersion === "number" ? pluginInfo.schemaVersion : null;
7822
8737
  const commands = Array.isArray(pluginInfo?.commands) ? pluginInfo.commands.filter((c) => typeof c === "string") : [];
7823
8738
  const head = [name, versionName ? `v${versionName}` : null].filter(Boolean).join(" ");
7824
8739
  const cmdText = commands.length > 0 ? commands.slice(0, 60).join(", ") : "(no commands reported)";
@@ -7826,10 +8741,126 @@ ${String(st.stdout || "").trim()}`
7826
8741
  return [
7827
8742
  `UnrealMCP capabilities detected: ${head}`,
7828
8743
  baseDir ? `Plugin path: ${baseDir}` : null,
8744
+ schemaVersion !== null ? `Schema version: ${schemaVersion}` : null,
7829
8745
  `Supported commands: ${cmdText}${truncated}`,
7830
- `Guidance: avoid guessing; prefer using only supported commands. If you get "Unknown command", run get_plugin_info and adjust.`
8746
+ `Guidance: avoid guessing; prefer using get_command_schema / list_capabilities when unsure about params.`
7831
8747
  ].filter(Boolean).join("\n");
7832
8748
  }
8749
+ mcp.registerTool("unreal_editor_launch", {
8750
+ title: "Unreal Editor: Launch Project",
8751
+ description: "Launch Unreal Editor for a given .uproject (no auto-restart). If the editor later crashes or becomes unreachable, Flockbay will abort the current agent run and report it in the chat.",
8752
+ inputSchema: {
8753
+ uprojectPath: z.z.string().describe("Absolute path to the .uproject file."),
8754
+ engineRoot: z.z.string().optional().describe("Optional Unreal Engine install root. Defaults to ENGINE_ROOT / UE_ENGINE_ROOT, or inferred from EngineAssociation when possible."),
8755
+ extraArgs: z.z.array(z.z.string()).optional().describe("Additional UnrealEditor command-line args (advanced).")
8756
+ }
8757
+ }, async (args) => runWithMcpToolCard("unreal_editor_launch", args, async () => {
8758
+ const uprojectPath = typeof args?.uprojectPath === "string" ? String(args.uprojectPath).trim() : "";
8759
+ const engineRootArg = typeof args?.engineRoot === "string" ? String(args.engineRoot).trim() : "";
8760
+ const extraArgs = Array.isArray(args?.extraArgs) ? args.extraArgs : [];
8761
+ if (!uprojectPath || !uprojectPath.toLowerCase().endsWith(".uproject") || !path.isAbsolute(uprojectPath)) {
8762
+ return {
8763
+ content: [{ type: "text", text: `Invalid uprojectPath (must be an absolute path to *.uproject): ${String(uprojectPath)}` }],
8764
+ isError: true
8765
+ };
8766
+ }
8767
+ if (!fs.existsSync(uprojectPath)) {
8768
+ return { content: [{ type: "text", text: `uprojectPath not found: ${uprojectPath}` }], isError: true };
8769
+ }
8770
+ const resolved = await resolveEngineRootForUproject({ uprojectPath, engineRootArg: engineRootArg || null });
8771
+ if (!resolved.ok) {
8772
+ return {
8773
+ content: [
8774
+ { type: "text", text: `Unable to resolve engineRoot for this project.` },
8775
+ { type: "text", text: resolved.errorMessage },
8776
+ ...resolved.suggestedEngineRoot ? [{ type: "text", text: `Suggested engineRoot: ${resolved.suggestedEngineRoot}` }] : []
8777
+ ],
8778
+ isError: true
8779
+ };
8780
+ }
8781
+ unrealEditorSupervisor.noteUnrealActivity();
8782
+ const launched = await unrealEditorSupervisor.launchEditor({
8783
+ uprojectPath,
8784
+ engineRoot: resolved.engineRoot,
8785
+ extraArgs
8786
+ });
8787
+ return {
8788
+ content: [
8789
+ { type: "text", text: `Launched Unreal Editor for: ${uprojectPath}` },
8790
+ {
8791
+ type: "text",
8792
+ text: JSON.stringify(
8793
+ {
8794
+ engineRoot: resolved.engineRoot,
8795
+ engineRootSource: resolved.source,
8796
+ pid: launched.pid,
8797
+ exePath: launched.exePath
8798
+ },
8799
+ null,
8800
+ 2
8801
+ )
8802
+ },
8803
+ { type: "text", text: "Next: wait for the editor to finish loading, then use UnrealMCP tools (ping / get_plugin_info / play_in_editor / etc)." }
8804
+ ],
8805
+ isError: false
8806
+ };
8807
+ }));
8808
+ mcp.registerTool("unreal_build_project", {
8809
+ title: "Unreal Build Project (UBT)",
8810
+ description: "Build the project via Unreal Build Tool (via Engine/Build/BatchFiles/Build.*). Returns structured errors (file/line) and a log path for deep debugging.",
8811
+ inputSchema: {
8812
+ uprojectPath: z.z.string().describe("Absolute path to the .uproject file."),
8813
+ engineRoot: z.z.string().optional().describe("Optional Unreal Engine install root. Defaults to ENGINE_ROOT / UE_ENGINE_ROOT, or inferred from EngineAssociation when possible."),
8814
+ configuration: z.z.enum(["Debug", "DebugGame", "Development", "Shipping"]).optional().describe("Build configuration (default Development)."),
8815
+ target: z.z.enum(["Editor", "Game"]).optional().describe("Build target (default Editor)."),
8816
+ timeoutMs: z.z.number().int().positive().optional().describe("Timeout in ms (default 20m)."),
8817
+ issuesLimit: z.z.number().int().positive().optional().describe("Max issues to return (default 250, max 2000).")
8818
+ }
8819
+ }, async (args) => runWithMcpToolCard("unreal_build_project", args, async () => {
8820
+ const uprojectPath = typeof args?.uprojectPath === "string" ? String(args.uprojectPath).trim() : "";
8821
+ const engineRootArg = typeof args?.engineRoot === "string" ? String(args.engineRoot).trim() : "";
8822
+ const timeoutMs = typeof args?.timeoutMs === "number" ? args.timeoutMs : void 0;
8823
+ if (!uprojectPath || !uprojectPath.toLowerCase().endsWith(".uproject") || !path.isAbsolute(uprojectPath)) {
8824
+ return {
8825
+ content: [{ type: "text", text: `Invalid uprojectPath (must be an absolute path to *.uproject): ${String(uprojectPath)}` }],
8826
+ isError: true
8827
+ };
8828
+ }
8829
+ if (!fs.existsSync(uprojectPath)) {
8830
+ return { content: [{ type: "text", text: `uprojectPath not found: ${uprojectPath}` }], isError: true };
8831
+ }
8832
+ const resolved = await resolveEngineRootForUproject({ uprojectPath, engineRootArg: engineRootArg || null });
8833
+ if (!resolved.ok) {
8834
+ return {
8835
+ content: [
8836
+ { type: "text", text: `Unable to resolve engineRoot for this project.` },
8837
+ { type: "text", text: resolved.errorMessage },
8838
+ ...resolved.suggestedEngineRoot ? [{ type: "text", text: `Suggested engineRoot: ${resolved.suggestedEngineRoot}` }] : []
8839
+ ],
8840
+ isError: true
8841
+ };
8842
+ }
8843
+ const configuration2 = args?.configuration ?? "Development";
8844
+ const target = args?.target ?? "Editor";
8845
+ const issuesLimit = typeof args?.issuesLimit === "number" ? args.issuesLimit : void 0;
8846
+ const result = await buildUnrealProject({
8847
+ uprojectPath,
8848
+ engineRoot: resolved.engineRoot,
8849
+ configuration: configuration2,
8850
+ target,
8851
+ timeoutMs,
8852
+ issuesLimit
8853
+ });
8854
+ const payload = { ...result, engineRoot: resolved.engineRoot, engineRootSource: resolved.source };
8855
+ return {
8856
+ content: [
8857
+ { type: "text", text: result.ok ? "Build succeeded." : "Build failed." },
8858
+ ...result.logPath ? [{ type: "text", text: `Log: ${result.logPath}` }] : [],
8859
+ { type: "text", text: JSON.stringify(payload, null, 2) }
8860
+ ],
8861
+ isError: !result.ok
8862
+ };
8863
+ }));
7833
8864
  mcp.registerTool("unreal_mcp_command", {
7834
8865
  title: "Unreal Editor Command (UnrealMCP)",
7835
8866
  description: "Send a single UnrealMCP command to the running Unreal Editor (engine plugin) and return the JSON response. Requires Unreal Editor running with the UnrealMCP plugin enabled.",
@@ -7842,10 +8873,12 @@ ${String(st.stdout || "").trim()}`
7842
8873
  const type = String(args.type || "").trim();
7843
8874
  const params = args.params && typeof args.params === "object" ? args.params : {};
7844
8875
  const timeoutMs = args.timeoutMs ?? 5e3;
8876
+ unrealEditorSupervisor.noteUnrealActivity();
7845
8877
  try {
7846
8878
  const pluginInfoWasCached = Boolean(unrealMcpPluginInfoCache);
7847
8879
  const pluginInfo = type !== "get_plugin_info" ? await getUnrealMcpPluginInfoBestEffort(timeoutMs) : null;
7848
8880
  const response = await types.sendUnrealMcpTcpCommand({ type, params, timeoutMs });
8881
+ unrealEditorSupervisor.noteUnrealReachable();
7849
8882
  return {
7850
8883
  content: [
7851
8884
  { type: "text", text: `UnrealMCP command ok: ${type}` },
@@ -7868,16 +8901,928 @@ ${String(st.stdout || "").trim()}`
7868
8901
  };
7869
8902
  }
7870
8903
  }));
7871
- mcp.registerTool("unreal_mcp_get_actors_in_level", {
7872
- title: "Unreal Actors In Level (UnrealMCP)",
7873
- description: "List all actors in the current Unreal Editor level via the UnrealMCP engine plugin. Requires Unreal Editor running with the UnrealMCP plugin enabled.",
8904
+ mcp.registerTool("unreal_mcp_list_capabilities", {
8905
+ title: "Unreal MCP: Capabilities",
8906
+ description: "Query the running UnrealMCP plugin for its build info, supported command list, and capability flags. Use this to avoid guessing tool availability/behavior.",
8907
+ inputSchema: {
8908
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 2000).")
8909
+ }
8910
+ }, async (args) => runWithMcpToolCard("unreal_mcp_list_capabilities", args, async () => {
8911
+ const timeoutMs = args?.timeoutMs ?? 2e3;
8912
+ unrealEditorSupervisor.noteUnrealActivity();
8913
+ const response = await types.sendUnrealMcpTcpCommand({ type: "list_capabilities", params: {}, timeoutMs });
8914
+ unrealEditorSupervisor.noteUnrealReachable();
8915
+ return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }], isError: false };
8916
+ }));
8917
+ mcp.registerTool("unreal_mcp_get_command_schema", {
8918
+ title: "Unreal MCP: Command Schema",
8919
+ description: "Fetch a per-command schema from the running UnrealMCP plugin (required params, aliases, examples). If command is omitted, returns all known schemas (can be large).",
7874
8920
  inputSchema: {
8921
+ command: z.z.string().optional().describe('Command type to fetch schema for (e.g. "create_blueprint").'),
8922
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 2000).")
8923
+ }
8924
+ }, async (args) => runWithMcpToolCard("unreal_mcp_get_command_schema", args, async () => {
8925
+ const timeoutMs = args?.timeoutMs ?? 2e3;
8926
+ const command = typeof args?.command === "string" ? String(args.command).trim() : "";
8927
+ unrealEditorSupervisor.noteUnrealActivity();
8928
+ const response = await types.sendUnrealMcpTcpCommand({
8929
+ type: "get_command_schema",
8930
+ params: command ? { command } : {},
8931
+ timeoutMs
8932
+ });
8933
+ unrealEditorSupervisor.noteUnrealReachable();
8934
+ return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }], isError: false };
8935
+ }));
8936
+ mcp.registerTool("unreal_mcp_search_blueprint_functions", {
8937
+ title: "Unreal MCP: Search Blueprint Functions",
8938
+ description: "Search for Blueprint-callable functions and return candidates (owner class path + signature hints) to use with add_blueprint_function_node.",
8939
+ inputSchema: {
8940
+ query: z.z.string().describe('Substring to search for (e.g. "MakeVector", "SetRelativeLocation").'),
8941
+ target: z.z.string().optional().describe('Optional target class constraint (e.g. "KismetMathLibrary").'),
8942
+ limit: z.z.number().int().positive().optional().describe("Max results (default 25)."),
7875
8943
  timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
7876
8944
  }
7877
- }, async (args) => runWithMcpToolCard("unreal_mcp_get_actors_in_level", args, async () => {
8945
+ }, async (args) => runWithMcpToolCard("unreal_mcp_search_blueprint_functions", args, async () => {
8946
+ const timeoutMs = args?.timeoutMs ?? 5e3;
8947
+ const query = String(args.query || "").trim();
8948
+ const target = typeof args?.target === "string" ? String(args.target).trim() : "";
8949
+ const limit = typeof args?.limit === "number" ? args.limit : void 0;
8950
+ unrealEditorSupervisor.noteUnrealActivity();
8951
+ const params = { query };
8952
+ if (target) params.target = target;
8953
+ if (typeof limit === "number") params.limit = limit;
8954
+ const response = await types.sendUnrealMcpTcpCommand({ type: "search_blueprint_functions", params, timeoutMs });
8955
+ unrealEditorSupervisor.noteUnrealReachable();
8956
+ return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }], isError: false };
8957
+ }));
8958
+ mcp.registerTool("unreal_mcp_resolve_blueprint_function", {
8959
+ title: "Unreal MCP: Resolve Blueprint Function",
8960
+ description: 'Resolve a Blueprint-callable function to an explicit owner class path usable with add_blueprint_function_node (reduces "Function not found" guesswork).',
8961
+ inputSchema: {
8962
+ functionName: z.z.string().describe('Function name to resolve (e.g. "MakeVector").'),
8963
+ target: z.z.string().optional().describe("Optional target class constraint."),
8964
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
8965
+ }
8966
+ }, async (args) => runWithMcpToolCard("unreal_mcp_resolve_blueprint_function", args, async () => {
8967
+ const timeoutMs = args?.timeoutMs ?? 5e3;
8968
+ const functionName = String(args.functionName || "").trim();
8969
+ const target = typeof args?.target === "string" ? String(args.target).trim() : "";
8970
+ unrealEditorSupervisor.noteUnrealActivity();
8971
+ const params = { function_name: functionName };
8972
+ if (target) params.target = target;
8973
+ const response = await types.sendUnrealMcpTcpCommand({ type: "resolve_blueprint_function", params, timeoutMs });
8974
+ unrealEditorSupervisor.noteUnrealReachable();
8975
+ return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }], isError: false };
8976
+ }));
8977
+ mcp.registerTool("unreal_mcp_search_assets", {
8978
+ title: "Unreal Search Assets (UnrealMCP)",
8979
+ description: "Search for assets in the current Unreal project via the UnrealMCP engine plugin (AssetRegistry-backed). Returns object paths suitable for place_asset/spawn_actor.",
8980
+ inputSchema: {
8981
+ query: z.z.string().optional().describe("Fuzzy search string (name/path)."),
8982
+ class: z.z.union([z.z.string(), z.z.array(z.z.string())]).optional().describe('Optional asset class filter (e.g. "StaticMesh", "Blueprint", "Material").'),
8983
+ root: z.z.string().optional().describe('Root path to search (default "/Game/").'),
8984
+ limit: z.z.number().int().positive().optional().describe("Max results (default 25, max 200)."),
8985
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
8986
+ }
8987
+ }, async (args) => runWithMcpToolCard("unreal_mcp_search_assets", args, async () => {
8988
+ const timeoutMs = args.timeoutMs ?? 5e3;
8989
+ unrealEditorSupervisor.noteUnrealActivity();
8990
+ try {
8991
+ const params = {};
8992
+ if (typeof args.query === "string" && args.query.trim()) params.query = args.query.trim();
8993
+ if (typeof args.root === "string" && args.root.trim()) params.root = args.root.trim();
8994
+ if (typeof args.limit === "number" && Number.isFinite(args.limit)) params.limit = args.limit;
8995
+ if (typeof args.class === "string" && args.class.trim()) params.class = args.class.trim();
8996
+ if (Array.isArray(args.class)) params.class = args.class;
8997
+ const response = await types.sendUnrealMcpTcpCommand({ type: "search_assets", params, timeoutMs });
8998
+ unrealEditorSupervisor.noteUnrealReachable();
8999
+ return {
9000
+ content: [
9001
+ { type: "text", text: "Unreal assets search ok." },
9002
+ { type: "text", text: JSON.stringify(response, null, 2) }
9003
+ ],
9004
+ isError: false
9005
+ };
9006
+ } catch (error) {
9007
+ return {
9008
+ content: [
9009
+ { type: "text", text: "Unreal assets search failed." },
9010
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9011
+ ],
9012
+ isError: true
9013
+ };
9014
+ }
9015
+ }));
9016
+ mcp.registerTool("unreal_mcp_get_asset_info", {
9017
+ title: "Unreal Asset Info (UnrealMCP)",
9018
+ description: "Get metadata for a single Unreal asset (class/path + optional dependencies) via the UnrealMCP engine plugin.",
9019
+ inputSchema: {
9020
+ objectPath: z.z.string().describe('Unreal asset object path (e.g. "/Game/MyPack/MyMesh.MyMesh").'),
9021
+ includeDependencies: z.z.boolean().optional().describe("Include package dependencies (default false)."),
9022
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9023
+ }
9024
+ }, async (args) => runWithMcpToolCard("unreal_mcp_get_asset_info", args, async () => {
9025
+ const timeoutMs = args.timeoutMs ?? 5e3;
9026
+ unrealEditorSupervisor.noteUnrealActivity();
9027
+ try {
9028
+ const params = { objectPath: String(args.objectPath || "").trim() };
9029
+ if (typeof args.includeDependencies === "boolean") params.includeDependencies = args.includeDependencies;
9030
+ const response = await types.sendUnrealMcpTcpCommand({ type: "get_asset_info", params, timeoutMs });
9031
+ unrealEditorSupervisor.noteUnrealReachable();
9032
+ return {
9033
+ content: [
9034
+ { type: "text", text: "Unreal asset info ok." },
9035
+ { type: "text", text: JSON.stringify(response, null, 2) }
9036
+ ],
9037
+ isError: false
9038
+ };
9039
+ } catch (error) {
9040
+ return {
9041
+ content: [
9042
+ { type: "text", text: "Unreal asset info failed." },
9043
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9044
+ ],
9045
+ isError: true
9046
+ };
9047
+ }
9048
+ }));
9049
+ mcp.registerTool("unreal_mcp_list_asset_packs", {
9050
+ title: "Unreal List Asset Packs (UnrealMCP)",
9051
+ description: 'List top-level /Game content folders (best-effort "asset packs") via the UnrealMCP engine plugin.',
9052
+ inputSchema: {
9053
+ limit: z.z.number().int().positive().optional().describe("Max number of packs (default 200)."),
9054
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9055
+ }
9056
+ }, async (args) => runWithMcpToolCard("unreal_mcp_list_asset_packs", args, async () => {
9057
+ const timeoutMs = args.timeoutMs ?? 5e3;
9058
+ unrealEditorSupervisor.noteUnrealActivity();
9059
+ try {
9060
+ const params = {};
9061
+ if (typeof args.limit === "number" && Number.isFinite(args.limit)) params.limit = args.limit;
9062
+ const response = await types.sendUnrealMcpTcpCommand({ type: "list_asset_packs", params, timeoutMs });
9063
+ unrealEditorSupervisor.noteUnrealReachable();
9064
+ return {
9065
+ content: [
9066
+ { type: "text", text: "Unreal asset packs list ok." },
9067
+ { type: "text", text: JSON.stringify(response, null, 2) }
9068
+ ],
9069
+ isError: false
9070
+ };
9071
+ } catch (error) {
9072
+ return {
9073
+ content: [
9074
+ { type: "text", text: "Unreal asset packs list failed." },
9075
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9076
+ ],
9077
+ isError: true
9078
+ };
9079
+ }
9080
+ }));
9081
+ mcp.registerTool("unreal_mcp_place_asset", {
9082
+ title: "Unreal Place Asset (UnrealMCP)",
9083
+ description: "Place an asset into the current Unreal level by object path (StaticMesh/Blueprint). Fails if PIE is running to avoid crashes.",
9084
+ inputSchema: {
9085
+ objectPath: z.z.string().describe('Unreal asset object path (e.g. "/Game/MyPack/MyMesh.MyMesh").'),
9086
+ name: z.z.string().optional().describe("Optional actor name (default: derived from asset name)."),
9087
+ location: z.z.array(z.z.number()).length(3).optional().describe("Spawn location [X,Y,Z] in cm."),
9088
+ rotation: z.z.array(z.z.number()).length(3).optional().describe("Spawn rotation [Pitch,Yaw,Roll] in degrees."),
9089
+ scale: z.z.array(z.z.number()).length(3).optional().describe("Spawn scale [X,Y,Z]."),
9090
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9091
+ }
9092
+ }, async (args) => runWithMcpToolCard("unreal_mcp_place_asset", args, async () => {
9093
+ const timeoutMs = args.timeoutMs ?? 5e3;
9094
+ unrealEditorSupervisor.noteUnrealActivity();
9095
+ try {
9096
+ const params = { objectPath: String(args.objectPath || "").trim() };
9097
+ if (typeof args.name === "string" && args.name.trim()) params.name = args.name.trim();
9098
+ if (Array.isArray(args.location)) params.location = args.location;
9099
+ if (Array.isArray(args.rotation)) params.rotation = args.rotation;
9100
+ if (Array.isArray(args.scale)) params.scale = args.scale;
9101
+ const response = await types.sendUnrealMcpTcpCommand({ type: "place_asset", params, timeoutMs });
9102
+ unrealEditorSupervisor.noteUnrealReachable();
9103
+ return {
9104
+ content: [
9105
+ { type: "text", text: "Placed asset into Unreal level." },
9106
+ { type: "text", text: JSON.stringify(response, null, 2) }
9107
+ ],
9108
+ isError: false
9109
+ };
9110
+ } catch (error) {
9111
+ return {
9112
+ content: [
9113
+ { type: "text", text: "Failed to place asset into Unreal level." },
9114
+ { type: "text", text: error instanceof Error ? error.message : String(error) },
9115
+ { type: "text", text: "Tip: use unreal_mcp_get_play_in_editor_status and stop PIE before placing assets." }
9116
+ ],
9117
+ isError: true
9118
+ };
9119
+ }
9120
+ }));
9121
+ mcp.registerTool("unreal_mcp_get_editor_context", {
9122
+ title: "Unreal Editor Context (UnrealMCP)",
9123
+ description: "Return a compact snapshot of editor context: map, selection, and active viewport camera (if any).",
9124
+ inputSchema: {
9125
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9126
+ }
9127
+ }, async (args) => runWithMcpToolCard("unreal_mcp_get_editor_context", args, async () => {
9128
+ const timeoutMs = args.timeoutMs ?? 5e3;
9129
+ unrealEditorSupervisor.noteUnrealActivity();
9130
+ try {
9131
+ const response = await types.sendUnrealMcpTcpCommand({ type: "get_editor_context", params: {}, timeoutMs });
9132
+ unrealEditorSupervisor.noteUnrealReachable();
9133
+ return {
9134
+ content: [
9135
+ { type: "text", text: "Fetched Unreal Editor context." },
9136
+ { type: "text", text: JSON.stringify(response, null, 2) }
9137
+ ],
9138
+ isError: false
9139
+ };
9140
+ } catch (error) {
9141
+ return {
9142
+ content: [
9143
+ { type: "text", text: "Failed to fetch Unreal Editor context." },
9144
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9145
+ ],
9146
+ isError: true
9147
+ };
9148
+ }
9149
+ }));
9150
+ mcp.registerTool("unreal_mcp_get_player_context", {
9151
+ title: "Unreal Player Context (PIE)",
9152
+ description: "Return a compact snapshot of runtime player context (PIE): isPlaying, pawn transform, and camera transform.",
9153
+ inputSchema: {
9154
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9155
+ }
9156
+ }, async (args) => runWithMcpToolCard("unreal_mcp_get_player_context", args, async () => {
9157
+ const timeoutMs = args.timeoutMs ?? 5e3;
9158
+ unrealEditorSupervisor.noteUnrealActivity();
9159
+ try {
9160
+ const response = await types.sendUnrealMcpTcpCommand({ type: "get_player_context", params: {}, timeoutMs });
9161
+ unrealEditorSupervisor.noteUnrealReachable();
9162
+ return {
9163
+ content: [
9164
+ { type: "text", text: "Fetched player context." },
9165
+ { type: "text", text: JSON.stringify(response, null, 2) }
9166
+ ],
9167
+ isError: false
9168
+ };
9169
+ } catch (error) {
9170
+ return {
9171
+ content: [
9172
+ { type: "text", text: "Failed to fetch player context." },
9173
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9174
+ ],
9175
+ isError: true
9176
+ };
9177
+ }
9178
+ }));
9179
+ mcp.registerTool("unreal_mcp_raycast_from_camera", {
9180
+ title: "Unreal Raycast From Camera (Editor/PIE)",
9181
+ description: "Raycast from the editor viewport camera or the PIE player camera to turn \u201Cthat surface\u201D into an exact hit location and normal.",
9182
+ inputSchema: {
9183
+ source: z.z.enum(["editor", "pie"]).optional().describe("Camera source (default editor)."),
9184
+ maxDistance: z.z.number().positive().optional().describe("Max distance in cm (default 10000)."),
9185
+ ignoreActors: z.z.array(z.z.string()).optional().describe("Actor names/labels to ignore (best-effort)."),
9186
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9187
+ }
9188
+ }, async (args) => runWithMcpToolCard("unreal_mcp_raycast_from_camera", args, async () => {
9189
+ const timeoutMs = args.timeoutMs ?? 5e3;
9190
+ unrealEditorSupervisor.noteUnrealActivity();
9191
+ try {
9192
+ const params = {};
9193
+ if (args.source) params.source = args.source;
9194
+ if (typeof args.maxDistance === "number" && Number.isFinite(args.maxDistance)) params.maxDistance = args.maxDistance;
9195
+ if (Array.isArray(args.ignoreActors)) params.ignoreActors = args.ignoreActors;
9196
+ const response = await types.sendUnrealMcpTcpCommand({ type: "raycast_from_camera", params, timeoutMs });
9197
+ unrealEditorSupervisor.noteUnrealReachable();
9198
+ return {
9199
+ content: [
9200
+ { type: "text", text: "Raycast completed." },
9201
+ { type: "text", text: JSON.stringify(response, null, 2) }
9202
+ ],
9203
+ isError: false
9204
+ };
9205
+ } catch (error) {
9206
+ return {
9207
+ content: [
9208
+ { type: "text", text: "Raycast failed." },
9209
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9210
+ ],
9211
+ isError: true
9212
+ };
9213
+ }
9214
+ }));
9215
+ mcp.registerTool("unreal_mcp_raycast_down", {
9216
+ title: "Unreal Raycast Down (Drop To Ground)",
9217
+ description: "Raycast straight down from a given startLocation to find ground/surface beneath. Useful for drop-to-ground placement.",
9218
+ inputSchema: {
9219
+ source: z.z.enum(["editor", "pie"]).optional().describe("World source (default editor)."),
9220
+ startLocation: z.z.array(z.z.number()).length(3).describe("Start location [X,Y,Z] in cm."),
9221
+ maxDistance: z.z.number().positive().optional().describe("Max distance in cm (default 100000)."),
9222
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9223
+ }
9224
+ }, async (args) => runWithMcpToolCard("unreal_mcp_raycast_down", args, async () => {
9225
+ const timeoutMs = args.timeoutMs ?? 5e3;
9226
+ unrealEditorSupervisor.noteUnrealActivity();
9227
+ try {
9228
+ const params = { startLocation: args.startLocation };
9229
+ if (args.source) params.source = args.source;
9230
+ if (typeof args.maxDistance === "number" && Number.isFinite(args.maxDistance)) params.maxDistance = args.maxDistance;
9231
+ const response = await types.sendUnrealMcpTcpCommand({ type: "raycast_down", params, timeoutMs });
9232
+ unrealEditorSupervisor.noteUnrealReachable();
9233
+ return {
9234
+ content: [
9235
+ { type: "text", text: "Raycast-down completed." },
9236
+ { type: "text", text: JSON.stringify(response, null, 2) }
9237
+ ],
9238
+ isError: false
9239
+ };
9240
+ } catch (error) {
9241
+ return {
9242
+ content: [
9243
+ { type: "text", text: "Raycast-down failed." },
9244
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9245
+ ],
9246
+ isError: true
9247
+ };
9248
+ }
9249
+ }));
9250
+ mcp.registerTool("unreal_mcp_get_actor_transform", {
9251
+ title: "Unreal Actor Transform (UnrealMCP)",
9252
+ description: "Get a single actor transform (location/rotation/scale) by actor name or label (best-effort).",
9253
+ inputSchema: {
9254
+ name: z.z.string().describe("Actor name or label."),
9255
+ source: z.z.enum(["editor", "pie"]).optional().describe("World source (default editor)."),
9256
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9257
+ }
9258
+ }, async (args) => runWithMcpToolCard("unreal_mcp_get_actor_transform", args, async () => {
9259
+ const timeoutMs = args.timeoutMs ?? 5e3;
9260
+ unrealEditorSupervisor.noteUnrealActivity();
9261
+ try {
9262
+ const params = { name: String(args.name || "").trim() };
9263
+ if (args.source) params.source = args.source;
9264
+ const response = await types.sendUnrealMcpTcpCommand({ type: "get_actor_transform", params, timeoutMs });
9265
+ unrealEditorSupervisor.noteUnrealReachable();
9266
+ return {
9267
+ content: [
9268
+ { type: "text", text: "Fetched actor transform." },
9269
+ { type: "text", text: JSON.stringify(response, null, 2) }
9270
+ ],
9271
+ isError: false
9272
+ };
9273
+ } catch (error) {
9274
+ return {
9275
+ content: [
9276
+ { type: "text", text: "Failed to fetch actor transform." },
9277
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9278
+ ],
9279
+ isError: true
9280
+ };
9281
+ }
9282
+ }));
9283
+ mcp.registerTool("unreal_mcp_get_actor_bounds", {
9284
+ title: "Unreal Actor Bounds (UnrealMCP)",
9285
+ description: "Get a single actor bounds (origin/extent + min/max) by actor name or label (best-effort).",
9286
+ inputSchema: {
9287
+ name: z.z.string().describe("Actor name or label."),
9288
+ source: z.z.enum(["editor", "pie"]).optional().describe("World source (default editor)."),
9289
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9290
+ }
9291
+ }, async (args) => runWithMcpToolCard("unreal_mcp_get_actor_bounds", args, async () => {
9292
+ const timeoutMs = args.timeoutMs ?? 5e3;
9293
+ unrealEditorSupervisor.noteUnrealActivity();
9294
+ try {
9295
+ const params = { name: String(args.name || "").trim() };
9296
+ if (args.source) params.source = args.source;
9297
+ const response = await types.sendUnrealMcpTcpCommand({ type: "get_actor_bounds", params, timeoutMs });
9298
+ unrealEditorSupervisor.noteUnrealReachable();
9299
+ return {
9300
+ content: [
9301
+ { type: "text", text: "Fetched actor bounds." },
9302
+ { type: "text", text: JSON.stringify(response, null, 2) }
9303
+ ],
9304
+ isError: false
9305
+ };
9306
+ } catch (error) {
9307
+ return {
9308
+ content: [
9309
+ { type: "text", text: "Failed to fetch actor bounds." },
9310
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9311
+ ],
9312
+ isError: true
9313
+ };
9314
+ }
9315
+ }));
9316
+ mcp.registerTool("unreal_mcp_create_landscape", {
9317
+ title: "Unreal Create Landscape (UnrealMCP)",
9318
+ description: "Create a new Landscape in the current editor level via the UnrealMCP engine plugin. Requires Unreal Editor running with the UnrealMCP plugin enabled. Fails if PIE is running.",
9319
+ inputSchema: {
9320
+ name: z.z.string().describe("New landscape actor name (must be unique)."),
9321
+ componentCountX: z.z.number().int().positive().optional().describe("Number of components in X (default 8)."),
9322
+ componentCountY: z.z.number().int().positive().optional().describe("Number of components in Y (default 8)."),
9323
+ sectionsPerComponent: z.z.number().int().optional().describe("Sections per component (1 or 2, default 1)."),
9324
+ quadsPerSection: z.z.number().int().positive().optional().describe("Quads per section (default 63)."),
9325
+ location: z.z.array(z.z.number()).length(3).optional().describe("Center location [X,Y,Z] in cm (default [0,0,0])."),
9326
+ rotation: z.z.array(z.z.number()).length(3).optional().describe("Rotation [Pitch,Yaw,Roll] in degrees (default [0,0,0])."),
9327
+ scale: z.z.array(z.z.number()).length(3).optional().describe("Scale [X,Y,Z] (default [100,100,100])."),
9328
+ materialPath: z.z.string().optional().describe('Optional landscape material object path (e.g. "/Game/\u2026/M_Landscape.M_Landscape").'),
9329
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 30000).")
9330
+ }
9331
+ }, async (args) => runWithMcpToolCard("unreal_mcp_create_landscape", args, async () => {
9332
+ const timeoutMs = args?.timeoutMs ?? 3e4;
9333
+ unrealEditorSupervisor.noteUnrealActivity();
9334
+ try {
9335
+ const params = {
9336
+ name: String(args?.name || "").trim()
9337
+ };
9338
+ if (!params.name) throw new Error("name is required.");
9339
+ if (typeof args?.componentCountX === "number") params.componentCountX = args.componentCountX;
9340
+ if (typeof args?.componentCountY === "number") params.componentCountY = args.componentCountY;
9341
+ if (typeof args?.sectionsPerComponent === "number") params.sectionsPerComponent = args.sectionsPerComponent;
9342
+ if (typeof args?.quadsPerSection === "number") params.quadsPerSection = args.quadsPerSection;
9343
+ if (Array.isArray(args?.location)) params.location = args.location;
9344
+ if (Array.isArray(args?.rotation)) params.rotation = args.rotation;
9345
+ if (Array.isArray(args?.scale)) params.scale = args.scale;
9346
+ if (typeof args?.materialPath === "string" && args.materialPath.trim()) {
9347
+ params.materialPath = args.materialPath.trim();
9348
+ }
9349
+ const response = await types.sendUnrealMcpTcpCommand({ type: "create_landscape", params, timeoutMs });
9350
+ unrealEditorSupervisor.noteUnrealReachable();
9351
+ return {
9352
+ content: [
9353
+ { type: "text", text: "Landscape created." },
9354
+ { type: "text", text: JSON.stringify(response, null, 2) }
9355
+ ],
9356
+ isError: false
9357
+ };
9358
+ } catch (error) {
9359
+ return {
9360
+ content: [
9361
+ { type: "text", text: "Failed to create landscape." },
9362
+ { type: "text", text: error instanceof Error ? error.message : String(error) },
9363
+ { type: "text", text: "Tip: stop PIE before creating a landscape." }
9364
+ ],
9365
+ isError: true
9366
+ };
9367
+ }
9368
+ }));
9369
+ mcp.registerTool("unreal_mcp_map_check", {
9370
+ title: "Unreal Map Check (UnrealMCP)",
9371
+ description: "Run Unreal Map Check and return structured issues. Uses Unreal\u2019s Message Log to capture results. Fails if PIE is running to avoid editor instability.",
9372
+ inputSchema: {
9373
+ includeWarnings: z.z.boolean().optional().describe("Include warnings in the issues list (default true)."),
9374
+ limit: z.z.number().int().positive().optional().describe("Max number of issues to return (default 200, max 1000)."),
9375
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 15000).")
9376
+ }
9377
+ }, async (args) => runWithMcpToolCard("unreal_mcp_map_check", args, async () => {
9378
+ const timeoutMs = args.timeoutMs ?? 15e3;
9379
+ unrealEditorSupervisor.noteUnrealActivity();
9380
+ try {
9381
+ const params = {};
9382
+ if (typeof args.includeWarnings === "boolean") params.includeWarnings = args.includeWarnings;
9383
+ if (typeof args.limit === "number" && Number.isFinite(args.limit)) params.limit = args.limit;
9384
+ const response = await types.sendUnrealMcpTcpCommand({ type: "map_check", params, timeoutMs });
9385
+ unrealEditorSupervisor.noteUnrealReachable();
9386
+ return {
9387
+ content: [
9388
+ { type: "text", text: "Map Check completed." },
9389
+ { type: "text", text: JSON.stringify(response, null, 2) }
9390
+ ],
9391
+ isError: false
9392
+ };
9393
+ } catch (error) {
9394
+ return {
9395
+ content: [
9396
+ { type: "text", text: "Map Check failed." },
9397
+ { type: "text", text: error instanceof Error ? error.message : String(error) },
9398
+ { type: "text", text: "Tip: stop PIE before running map_check." }
9399
+ ],
9400
+ isError: true
9401
+ };
9402
+ }
9403
+ }));
9404
+ mcp.registerTool("unreal_mcp_compile_blueprints_all", {
9405
+ title: "Unreal Compile All Blueprints (UnrealMCP)",
9406
+ description: "Compile all Blueprints (optionally under specific content paths) and return a structured list of compile messages. Fails if PIE is running.",
9407
+ inputSchema: {
9408
+ paths: z.z.array(z.z.string()).optional().describe('Root content paths to search (default ["/Game"]).'),
9409
+ includeWarnings: z.z.boolean().optional().describe("Include warnings in the per-blueprint messages list (default true)."),
9410
+ limit: z.z.number().int().positive().optional().describe("Max number of blueprints to compile (default 500, max 10000)."),
9411
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 60000).")
9412
+ }
9413
+ }, async (args) => runWithMcpToolCard("unreal_mcp_compile_blueprints_all", args, async () => {
9414
+ const timeoutMs = args.timeoutMs ?? 6e4;
9415
+ unrealEditorSupervisor.noteUnrealActivity();
9416
+ try {
9417
+ const params = {};
9418
+ if (Array.isArray(args.paths)) params.paths = args.paths;
9419
+ if (typeof args.includeWarnings === "boolean") params.includeWarnings = args.includeWarnings;
9420
+ if (typeof args.limit === "number" && Number.isFinite(args.limit)) params.limit = args.limit;
9421
+ const response = await types.sendUnrealMcpTcpCommand({ type: "compile_blueprints_all", params, timeoutMs });
9422
+ unrealEditorSupervisor.noteUnrealReachable();
9423
+ return {
9424
+ content: [
9425
+ { type: "text", text: "Blueprint compile completed." },
9426
+ { type: "text", text: JSON.stringify(response, null, 2) }
9427
+ ],
9428
+ isError: false
9429
+ };
9430
+ } catch (error) {
9431
+ return {
9432
+ content: [
9433
+ { type: "text", text: "Blueprint compile failed." },
9434
+ { type: "text", text: error instanceof Error ? error.message : String(error) },
9435
+ { type: "text", text: "Tip: stop PIE before compiling Blueprints." }
9436
+ ],
9437
+ isError: true
9438
+ };
9439
+ }
9440
+ }));
9441
+ mcp.registerTool("unreal_mcp_save_all", {
9442
+ title: "Unreal Save All (UnrealMCP)",
9443
+ description: "Save all dirty packages (maps + content) in the running Unreal Editor without prompting. Intended to prevent leaving editor changes unsaved. Fails if PIE is running.",
9444
+ inputSchema: {
9445
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 60000).")
9446
+ }
9447
+ }, async (args) => runWithMcpToolCard("unreal_mcp_save_all", args, async () => {
9448
+ const timeoutMs = args?.timeoutMs ?? 6e4;
9449
+ unrealEditorSupervisor.noteUnrealActivity();
9450
+ try {
9451
+ const response = await types.sendUnrealMcpTcpCommand({ type: "save_all", params: {}, timeoutMs });
9452
+ unrealEditorSupervisor.noteUnrealReachable();
9453
+ return {
9454
+ content: [
9455
+ { type: "text", text: "Save all completed." },
9456
+ { type: "text", text: JSON.stringify(response, null, 2) }
9457
+ ],
9458
+ isError: false
9459
+ };
9460
+ } catch (error) {
9461
+ return {
9462
+ content: [
9463
+ { type: "text", text: "Save all failed." },
9464
+ { type: "text", text: error instanceof Error ? error.message : String(error) },
9465
+ { type: "text", text: "Tip: stop PIE, resolve any editor save prompts/errors (Save As, checkout), then retry." }
9466
+ ],
9467
+ isError: true
9468
+ };
9469
+ }
9470
+ }));
9471
+ const vec = {
9472
+ add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
9473
+ sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],
9474
+ mul: (a, s) => [a[0] * s, a[1] * s, a[2] * s],
9475
+ dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
9476
+ len: (a) => Math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]),
9477
+ norm: (a) => {
9478
+ const l = Math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]);
9479
+ if (!Number.isFinite(l) || l <= 1e-8) return [0, 0, 0];
9480
+ return [a[0] / l, a[1] / l, a[2] / l];
9481
+ },
9482
+ cross: (a, b) => [
9483
+ a[1] * b[2] - a[2] * b[1],
9484
+ a[2] * b[0] - a[0] * b[2],
9485
+ a[0] * b[1] - a[1] * b[0]
9486
+ ],
9487
+ isFinite3: (a) => Array.isArray(a) && a.length === 3 && a.every((x) => typeof x === "number" && Number.isFinite(x))
9488
+ };
9489
+ function degToRad(deg) {
9490
+ return deg * Math.PI / 180;
9491
+ }
9492
+ function radToDeg(rad) {
9493
+ return rad * 180 / Math.PI;
9494
+ }
9495
+ function forwardRightUpFromRotator(rot) {
9496
+ const pitch = degToRad(rot[0]);
9497
+ const yaw = degToRad(rot[1]);
9498
+ const roll = degToRad(rot[2]);
9499
+ const cp = Math.cos(pitch);
9500
+ const sp = Math.sin(pitch);
9501
+ const cy = Math.cos(yaw);
9502
+ const sy = Math.sin(yaw);
9503
+ const cr = Math.cos(roll);
9504
+ const sr = Math.sin(roll);
9505
+ const forward = [cp * cy, cp * sy, sp];
9506
+ const right = [
9507
+ sr * sp * cy + cr * -sy,
9508
+ sr * sp * sy + cr * cy,
9509
+ -sr * cp
9510
+ ];
9511
+ const up = [
9512
+ cr * sp * cy + -sr * -sy,
9513
+ cr * sp * sy + -sr * cy,
9514
+ cr * cp
9515
+ ];
9516
+ return { forward: vec.norm(forward), right: vec.norm(right), up: vec.norm(up) };
9517
+ }
9518
+ function rotatorFromBasis(args) {
9519
+ const f = vec.norm(args.forward);
9520
+ const r = vec.norm(args.right);
9521
+ const u = vec.norm(args.up);
9522
+ const yaw = radToDeg(Math.atan2(f[1], f[0]));
9523
+ const pitch = radToDeg(Math.atan2(f[2], Math.sqrt(f[0] * f[0] + f[1] * f[1])));
9524
+ const roll = radToDeg(Math.atan2(r[2], u[2]));
9525
+ return [pitch, yaw, roll];
9526
+ }
9527
+ function unwrapUnrealMcpResult(raw) {
9528
+ if (raw?.result && typeof raw.result === "object") return raw.result;
9529
+ return raw;
9530
+ }
9531
+ mcp.registerTool("unreal_place_asset_relative", {
9532
+ title: "Unreal Place Asset Relative (Context-Aware)",
9533
+ description: "High-level helper built from context/raycast primitives: place an asset relative to selection/camera/player/hit result, with optional align-to-surface and drop-to-ground.",
9534
+ inputSchema: {
9535
+ objectPath: z.z.string().describe('Unreal asset object path (e.g. "/Game/MyPack/MyMesh.MyMesh").'),
9536
+ name: z.z.string().optional().describe("Optional actor name (default: derived from asset name)."),
9537
+ anchor: z.z.enum(["selection", "editor_camera", "player", "player_camera", "hit_result", "actor"]).describe("Reference anchor for placement."),
9538
+ actorName: z.z.string().optional().describe('When anchor="actor", the actor name/label to anchor to.'),
9539
+ offset: z.z.array(z.z.number()).length(3).optional().describe("Offset in anchor local axes [forwardCm, rightCm, upCm] (default [200,0,0])."),
9540
+ alignToSurface: z.z.boolean().optional().describe("When using hit_result or drop-to-ground hits, align actor up to hit normal (default false)."),
9541
+ dropToGround: z.z.boolean().optional().describe("After computing target location, raycast down and snap to the hit location (default false)."),
9542
+ raySource: z.z.enum(["editor", "pie"]).optional().describe('When anchor="hit_result", which camera to raycast from (default editor).'),
9543
+ maxDistance: z.z.number().positive().optional().describe("Raycast max distance in cm (default 10000)."),
9544
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 8000).")
9545
+ }
9546
+ }, async (args) => runWithMcpToolCard("unreal_place_asset_relative", args, async () => {
9547
+ const timeoutMs = args.timeoutMs ?? 8e3;
9548
+ const offset = Array.isArray(args.offset) && args.offset.length === 3 ? args.offset : [200, 0, 0];
9549
+ const alignToSurface = args.alignToSurface ?? false;
9550
+ const dropToGround = args.dropToGround ?? false;
9551
+ const maxDistance = typeof args.maxDistance === "number" ? args.maxDistance : 1e4;
9552
+ const raySource = args.raySource ?? "editor";
9553
+ unrealEditorSupervisor.noteUnrealActivity();
9554
+ try {
9555
+ const anchorRaw = String(args.anchor || "").trim();
9556
+ const anchor = anchorRaw === "player" ? "player_camera" : anchorRaw;
9557
+ const objectPath = String(args.objectPath || "").trim();
9558
+ if (!objectPath) throw new Error("Missing objectPath.");
9559
+ if (!vec.isFinite3(offset)) throw new Error("Invalid offset (expected [forward,right,up] numbers).");
9560
+ let origin = null;
9561
+ let rot = null;
9562
+ let surfaceNormal = null;
9563
+ if (anchor === "selection" || anchor === "editor_camera") {
9564
+ const ctxRaw = await types.sendUnrealMcpTcpCommand({ type: "get_editor_context", params: {}, timeoutMs });
9565
+ unrealEditorSupervisor.noteUnrealReachable();
9566
+ const ctx = unwrapUnrealMcpResult(ctxRaw);
9567
+ if (anchor === "selection") {
9568
+ const sel = Array.isArray(ctx?.selection) ? ctx.selection : [];
9569
+ if (sel.length === 0) throw new Error("No selected actors in Unreal Editor. Select an actor and retry.");
9570
+ const first = sel[0];
9571
+ origin = Array.isArray(first?.location) ? first.location : null;
9572
+ rot = Array.isArray(first?.rotation) ? first.rotation : null;
9573
+ } else {
9574
+ const cam = ctx?.viewportCamera;
9575
+ if (!cam || cam === null) throw new Error("No active viewport camera. Click the viewport and retry.");
9576
+ origin = Array.isArray(cam?.location) ? cam.location : null;
9577
+ rot = Array.isArray(cam?.rotation) ? cam.rotation : null;
9578
+ }
9579
+ } else if (anchor === "player_camera") {
9580
+ const pcRaw = await types.sendUnrealMcpTcpCommand({ type: "get_player_context", params: {}, timeoutMs });
9581
+ unrealEditorSupervisor.noteUnrealReachable();
9582
+ const pc = unwrapUnrealMcpResult(pcRaw);
9583
+ if (!pc?.isPlaying) throw new Error("PIE is not running. Start PIE and retry (or use editor_camera/selection anchors).");
9584
+ const cam = pc?.camera;
9585
+ origin = Array.isArray(cam?.location) ? cam.location : null;
9586
+ rot = Array.isArray(cam?.rotation) ? cam.rotation : null;
9587
+ } else if (anchor === "actor") {
9588
+ const actorName = String(args.actorName || "").trim();
9589
+ if (!actorName) throw new Error('anchor="actor" requires actorName.');
9590
+ const tRaw = await types.sendUnrealMcpTcpCommand({ type: "get_actor_transform", params: { name: actorName, source: "editor" }, timeoutMs });
9591
+ unrealEditorSupervisor.noteUnrealReachable();
9592
+ const t = unwrapUnrealMcpResult(tRaw);
9593
+ origin = Array.isArray(t?.location) ? t.location : null;
9594
+ rot = Array.isArray(t?.rotation) ? t.rotation : null;
9595
+ } else if (anchor === "hit_result") {
9596
+ const hitRaw = await types.sendUnrealMcpTcpCommand({
9597
+ type: "raycast_from_camera",
9598
+ params: { source: raySource, maxDistance },
9599
+ timeoutMs
9600
+ });
9601
+ unrealEditorSupervisor.noteUnrealReachable();
9602
+ const hit = unwrapUnrealMcpResult(hitRaw);
9603
+ if (!hit?.hit) throw new Error("Raycast did not hit anything.");
9604
+ origin = Array.isArray(hit?.location) ? hit.location : null;
9605
+ surfaceNormal = Array.isArray(hit?.normal) ? hit.normal : null;
9606
+ const ctxRot = raySource === "pie" ? unwrapUnrealMcpResult(await types.sendUnrealMcpTcpCommand({ type: "get_player_context", params: {}, timeoutMs }))?.camera?.rotation : unwrapUnrealMcpResult(await types.sendUnrealMcpTcpCommand({ type: "get_editor_context", params: {}, timeoutMs }))?.viewportCamera?.rotation;
9607
+ rot = Array.isArray(ctxRot) ? ctxRot : [0, 0, 0];
9608
+ } else {
9609
+ throw new Error(`Unknown anchor: ${anchor}`);
9610
+ }
9611
+ if (!vec.isFinite3(origin)) throw new Error("Anchor origin is missing or invalid.");
9612
+ if (!rot || !vec.isFinite3(rot)) throw new Error("Anchor rotation is missing or invalid.");
9613
+ const basis = forwardRightUpFromRotator(rot);
9614
+ let target = vec.add(origin, vec.add(vec.mul(basis.forward, offset[0]), vec.add(vec.mul(basis.right, offset[1]), vec.mul(basis.up, offset[2]))));
9615
+ let finalNormal = surfaceNormal;
9616
+ if (dropToGround) {
9617
+ const start = vec.add(target, [0, 0, 1e4]);
9618
+ const downRaw = await types.sendUnrealMcpTcpCommand({
9619
+ type: "raycast_down",
9620
+ params: { source: "editor", startLocation: start, maxDistance: 2e5 },
9621
+ timeoutMs
9622
+ });
9623
+ unrealEditorSupervisor.noteUnrealReachable();
9624
+ const down = unwrapUnrealMcpResult(downRaw);
9625
+ if (down?.hit && Array.isArray(down?.location)) {
9626
+ target = down.location;
9627
+ if (Array.isArray(down?.normal)) finalNormal = down.normal;
9628
+ } else {
9629
+ throw new Error("dropToGround=true but raycast_down did not hit anything.");
9630
+ }
9631
+ }
9632
+ let outRot = rot;
9633
+ if (alignToSurface && vec.isFinite3(finalNormal)) {
9634
+ const up = vec.norm(finalNormal);
9635
+ let fwd = basis.forward;
9636
+ fwd = vec.sub(fwd, vec.mul(up, vec.dot(fwd, up)));
9637
+ if (vec.len(fwd) <= 1e-4) {
9638
+ fwd = vec.cross([0, 1, 0], up);
9639
+ }
9640
+ fwd = vec.norm(fwd);
9641
+ const right = vec.norm(vec.cross(up, fwd));
9642
+ const fixedFwd = vec.norm(vec.cross(right, up));
9643
+ outRot = rotatorFromBasis({ forward: fixedFwd, right, up });
9644
+ }
9645
+ const params = {
9646
+ objectPath,
9647
+ ...typeof args.name === "string" && args.name.trim() ? { name: args.name.trim() } : {},
9648
+ location: target,
9649
+ rotation: outRot
9650
+ };
9651
+ const placed = await types.sendUnrealMcpTcpCommand({ type: "place_asset", params, timeoutMs: Math.max(timeoutMs, 1e4) });
9652
+ unrealEditorSupervisor.noteUnrealReachable();
9653
+ const payload = {
9654
+ anchor,
9655
+ origin,
9656
+ offset,
9657
+ location: target,
9658
+ rotation: outRot,
9659
+ ...finalNormal ? { normal: finalNormal } : {},
9660
+ placed
9661
+ };
9662
+ return {
9663
+ content: [
9664
+ { type: "text", text: "Placed asset relative to context." },
9665
+ { type: "text", text: JSON.stringify(payload, null, 2) }
9666
+ ],
9667
+ isError: false
9668
+ };
9669
+ } catch (error) {
9670
+ return {
9671
+ content: [
9672
+ { type: "text", text: "Failed to place asset relative to context." },
9673
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9674
+ ],
9675
+ isError: true
9676
+ };
9677
+ }
9678
+ }));
9679
+ mcp.registerTool("unreal_spawn_actor_relative", {
9680
+ title: "Unreal Spawn Actor Relative (Context-Aware)",
9681
+ description: "High-level helper built from context/raycast primitives: spawn an actor type relative to selection/camera/player/hit result.",
9682
+ inputSchema: {
9683
+ type: z.z.string().describe('Actor class/type (e.g. "PointLight", "CameraActor", "StaticMeshActor").'),
9684
+ name: z.z.string().describe("Actor name."),
9685
+ anchor: z.z.enum(["selection", "editor_camera", "player", "player_camera", "hit_result", "actor"]).describe("Reference anchor for placement."),
9686
+ actorName: z.z.string().optional().describe('When anchor="actor", the actor name/label to anchor to.'),
9687
+ offset: z.z.array(z.z.number()).length(3).optional().describe("Offset in anchor local axes [forwardCm, rightCm, upCm] (default [200,0,0])."),
9688
+ alignToSurface: z.z.boolean().optional().describe("When using hit_result or drop-to-ground hits, align actor up to hit normal (default false)."),
9689
+ dropToGround: z.z.boolean().optional().describe("After computing target location, raycast down and snap to the hit location (default false)."),
9690
+ raySource: z.z.enum(["editor", "pie"]).optional().describe('When anchor="hit_result", which camera to raycast from (default editor).'),
9691
+ maxDistance: z.z.number().positive().optional().describe("Raycast max distance in cm (default 10000)."),
9692
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 8000).")
9693
+ }
9694
+ }, async (args) => runWithMcpToolCard("unreal_spawn_actor_relative", args, async () => {
9695
+ const timeoutMs = args.timeoutMs ?? 8e3;
9696
+ const offset = Array.isArray(args.offset) && args.offset.length === 3 ? args.offset : [200, 0, 0];
9697
+ const alignToSurface = args.alignToSurface ?? false;
9698
+ const dropToGround = args.dropToGround ?? false;
9699
+ const maxDistance = typeof args.maxDistance === "number" ? args.maxDistance : 1e4;
9700
+ const raySource = args.raySource ?? "editor";
9701
+ unrealEditorSupervisor.noteUnrealActivity();
9702
+ try {
9703
+ const type = String(args.type || "").trim();
9704
+ const name = String(args.name || "").trim();
9705
+ const anchorRaw = String(args.anchor || "").trim();
9706
+ const anchor = anchorRaw === "player" ? "player_camera" : anchorRaw;
9707
+ if (!type) throw new Error("Missing type.");
9708
+ if (!name) throw new Error("Missing name.");
9709
+ if (!vec.isFinite3(offset)) throw new Error("Invalid offset (expected [forward,right,up] numbers).");
9710
+ let origin = null;
9711
+ let rot = null;
9712
+ let surfaceNormal = null;
9713
+ if (anchor === "selection" || anchor === "editor_camera") {
9714
+ const ctx = unwrapUnrealMcpResult(await types.sendUnrealMcpTcpCommand({ type: "get_editor_context", params: {}, timeoutMs }));
9715
+ unrealEditorSupervisor.noteUnrealReachable();
9716
+ if (anchor === "selection") {
9717
+ const sel = Array.isArray(ctx?.selection) ? ctx.selection : [];
9718
+ if (sel.length === 0) throw new Error("No selected actors in Unreal Editor. Select an actor and retry.");
9719
+ const first = sel[0];
9720
+ origin = Array.isArray(first?.location) ? first.location : null;
9721
+ rot = Array.isArray(first?.rotation) ? first.rotation : null;
9722
+ } else {
9723
+ const cam = ctx?.viewportCamera;
9724
+ if (!cam || cam === null) throw new Error("No active viewport camera. Click the viewport and retry.");
9725
+ origin = Array.isArray(cam?.location) ? cam.location : null;
9726
+ rot = Array.isArray(cam?.rotation) ? cam.rotation : null;
9727
+ }
9728
+ } else if (anchor === "player_camera") {
9729
+ const pc = unwrapUnrealMcpResult(await types.sendUnrealMcpTcpCommand({ type: "get_player_context", params: {}, timeoutMs }));
9730
+ unrealEditorSupervisor.noteUnrealReachable();
9731
+ if (!pc?.isPlaying) throw new Error("PIE is not running. Start PIE and retry (or use editor_camera/selection anchors).");
9732
+ const cam = pc?.camera;
9733
+ origin = Array.isArray(cam?.location) ? cam.location : null;
9734
+ rot = Array.isArray(cam?.rotation) ? cam.rotation : null;
9735
+ } else if (anchor === "actor") {
9736
+ const actorName = String(args.actorName || "").trim();
9737
+ if (!actorName) throw new Error('anchor="actor" requires actorName.');
9738
+ const t = unwrapUnrealMcpResult(await types.sendUnrealMcpTcpCommand({ type: "get_actor_transform", params: { name: actorName, source: "editor" }, timeoutMs }));
9739
+ unrealEditorSupervisor.noteUnrealReachable();
9740
+ origin = Array.isArray(t?.location) ? t.location : null;
9741
+ rot = Array.isArray(t?.rotation) ? t.rotation : null;
9742
+ } else if (anchor === "hit_result") {
9743
+ const hit = unwrapUnrealMcpResult(await types.sendUnrealMcpTcpCommand({ type: "raycast_from_camera", params: { source: raySource, maxDistance }, timeoutMs }));
9744
+ unrealEditorSupervisor.noteUnrealReachable();
9745
+ if (!hit?.hit) throw new Error("Raycast did not hit anything.");
9746
+ origin = Array.isArray(hit?.location) ? hit.location : null;
9747
+ surfaceNormal = Array.isArray(hit?.normal) ? hit.normal : null;
9748
+ const ctxRot = raySource === "pie" ? unwrapUnrealMcpResult(await types.sendUnrealMcpTcpCommand({ type: "get_player_context", params: {}, timeoutMs }))?.camera?.rotation : unwrapUnrealMcpResult(await types.sendUnrealMcpTcpCommand({ type: "get_editor_context", params: {}, timeoutMs }))?.viewportCamera?.rotation;
9749
+ rot = Array.isArray(ctxRot) ? ctxRot : [0, 0, 0];
9750
+ } else {
9751
+ throw new Error(`Unknown anchor: ${anchor}`);
9752
+ }
9753
+ if (!vec.isFinite3(origin)) throw new Error("Anchor origin is missing or invalid.");
9754
+ if (!rot || !vec.isFinite3(rot)) throw new Error("Anchor rotation is missing or invalid.");
9755
+ const basis = forwardRightUpFromRotator(rot);
9756
+ let target = vec.add(origin, vec.add(vec.mul(basis.forward, offset[0]), vec.add(vec.mul(basis.right, offset[1]), vec.mul(basis.up, offset[2]))));
9757
+ let finalNormal = surfaceNormal;
9758
+ if (dropToGround) {
9759
+ const start = vec.add(target, [0, 0, 1e4]);
9760
+ const down = unwrapUnrealMcpResult(await types.sendUnrealMcpTcpCommand({ type: "raycast_down", params: { source: "editor", startLocation: start, maxDistance: 2e5 }, timeoutMs }));
9761
+ unrealEditorSupervisor.noteUnrealReachable();
9762
+ if (down?.hit && Array.isArray(down?.location)) {
9763
+ target = down.location;
9764
+ if (Array.isArray(down?.normal)) finalNormal = down.normal;
9765
+ } else {
9766
+ throw new Error("dropToGround=true but raycast_down did not hit anything.");
9767
+ }
9768
+ }
9769
+ let outRot = rot;
9770
+ if (alignToSurface && vec.isFinite3(finalNormal)) {
9771
+ const up = vec.norm(finalNormal);
9772
+ let fwd = basis.forward;
9773
+ fwd = vec.sub(fwd, vec.mul(up, vec.dot(fwd, up)));
9774
+ if (vec.len(fwd) <= 1e-4) {
9775
+ fwd = vec.cross([0, 1, 0], up);
9776
+ }
9777
+ fwd = vec.norm(fwd);
9778
+ const right = vec.norm(vec.cross(up, fwd));
9779
+ const fixedFwd = vec.norm(vec.cross(right, up));
9780
+ outRot = rotatorFromBasis({ forward: fixedFwd, right, up });
9781
+ }
9782
+ const spawned = await types.sendUnrealMcpTcpCommand({
9783
+ type: "spawn_actor",
9784
+ params: { type, name, location: target, rotation: outRot },
9785
+ timeoutMs: Math.max(timeoutMs, 1e4)
9786
+ });
9787
+ unrealEditorSupervisor.noteUnrealReachable();
9788
+ const payload = {
9789
+ anchor,
9790
+ origin,
9791
+ offset,
9792
+ location: target,
9793
+ rotation: outRot,
9794
+ ...finalNormal ? { normal: finalNormal } : {},
9795
+ spawned
9796
+ };
9797
+ return {
9798
+ content: [
9799
+ { type: "text", text: "Spawned actor relative to context." },
9800
+ { type: "text", text: JSON.stringify(payload, null, 2) }
9801
+ ],
9802
+ isError: false
9803
+ };
9804
+ } catch (error) {
9805
+ return {
9806
+ content: [
9807
+ { type: "text", text: "Failed to spawn actor relative to context." },
9808
+ { type: "text", text: error instanceof Error ? error.message : String(error) }
9809
+ ],
9810
+ isError: true
9811
+ };
9812
+ }
9813
+ }));
9814
+ mcp.registerTool("unreal_mcp_get_actors_in_level", {
9815
+ title: "Unreal Actors In Level (UnrealMCP)",
9816
+ description: "List all actors in the current Unreal Editor level via the UnrealMCP engine plugin. Requires Unreal Editor running with the UnrealMCP plugin enabled.",
9817
+ inputSchema: {
9818
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms (default 5000).")
9819
+ }
9820
+ }, async (args) => runWithMcpToolCard("unreal_mcp_get_actors_in_level", args, async () => {
7878
9821
  const timeoutMs = args.timeoutMs ?? 5e3;
9822
+ unrealEditorSupervisor.noteUnrealActivity();
7879
9823
  try {
7880
9824
  const response = await types.sendUnrealMcpTcpCommand({ type: "get_actors_in_level", params: {}, timeoutMs });
9825
+ unrealEditorSupervisor.noteUnrealReachable();
7881
9826
  const actors = Array.isArray(response?.actors) ? response.actors : Array.isArray(response?.result?.actors) ? response.result.actors : null;
7882
9827
  if (!actors) {
7883
9828
  return {
@@ -7914,8 +9859,10 @@ ${String(st.stdout || "").trim()}`
7914
9859
  }
7915
9860
  }, async (args) => runWithMcpToolCard("unreal_mcp_get_play_in_editor_status", args, async () => {
7916
9861
  const timeoutMs = args.timeoutMs ?? 5e3;
9862
+ unrealEditorSupervisor.noteUnrealActivity();
7917
9863
  try {
7918
9864
  const response = await types.sendUnrealMcpTcpCommand({ type: "get_play_in_editor_status", params: {}, timeoutMs });
9865
+ unrealEditorSupervisor.noteUnrealReachable();
7919
9866
  return {
7920
9867
  content: [
7921
9868
  { type: "text", text: "Fetched Play-In-Editor status." },
@@ -7942,6 +9889,7 @@ ${String(st.stdout || "").trim()}`
7942
9889
  }
7943
9890
  }, async (args) => runWithMcpToolCard("unreal_mcp_editor_health", args, async () => {
7944
9891
  const timeoutMs = args.timeoutMs ?? 1500;
9892
+ unrealEditorSupervisor.noteUnrealActivity();
7945
9893
  const classify = (message) => {
7946
9894
  const m = message.toLowerCase();
7947
9895
  if (m.includes("econnrefused")) return { state: "offline", hint: "UnrealMCP TCP port is not accepting connections (Unreal Editor likely closed/crashed or plugin not running)." };
@@ -7951,10 +9899,12 @@ ${String(st.stdout || "").trim()}`
7951
9899
  };
7952
9900
  try {
7953
9901
  const ping = await types.sendUnrealMcpTcpCommand({ type: "ping", params: {}, timeoutMs });
9902
+ unrealEditorSupervisor.noteUnrealReachable();
7954
9903
  const pluginInfo = await getUnrealMcpPluginInfoBestEffort(timeoutMs);
7955
9904
  let pieStatus = null;
7956
9905
  try {
7957
9906
  pieStatus = await types.sendUnrealMcpTcpCommand({ type: "get_play_in_editor_status", params: {}, timeoutMs });
9907
+ unrealEditorSupervisor.noteUnrealReachable();
7958
9908
  } catch {
7959
9909
  pieStatus = null;
7960
9910
  }
@@ -7994,8 +9944,10 @@ ${String(st.stdout || "").trim()}`
7994
9944
  }
7995
9945
  }, async (args) => runWithMcpToolCard("unreal_mcp_play_in_editor", args, async () => {
7996
9946
  const timeoutMs = args.timeoutMs ?? 5e3;
9947
+ unrealEditorSupervisor.noteUnrealActivity();
7997
9948
  try {
7998
9949
  const response = await types.sendUnrealMcpTcpCommand({ type: "play_in_editor", params: {}, timeoutMs });
9950
+ unrealEditorSupervisor.noteUnrealReachable();
7999
9951
  return {
8000
9952
  content: [
8001
9953
  { type: "text", text: "Requested Play In Editor (PIE) in active viewport." },
@@ -8022,8 +9974,10 @@ ${String(st.stdout || "").trim()}`
8022
9974
  }
8023
9975
  }, async (args) => runWithMcpToolCard("unreal_mcp_play_in_editor_windowed", args, async () => {
8024
9976
  const timeoutMs = args.timeoutMs ?? 5e3;
9977
+ unrealEditorSupervisor.noteUnrealActivity();
8025
9978
  try {
8026
9979
  const response = await types.sendUnrealMcpTcpCommand({ type: "play_in_editor_windowed", params: {}, timeoutMs });
9980
+ unrealEditorSupervisor.noteUnrealReachable();
8027
9981
  return {
8028
9982
  content: [
8029
9983
  { type: "text", text: "Requested Play In Editor (PIE) in a new editor window." },
@@ -8051,8 +10005,10 @@ ${String(st.stdout || "").trim()}`
8051
10005
  }
8052
10006
  }, async (args) => runWithMcpToolCard("unreal_mcp_stop_play_in_editor", args, async () => {
8053
10007
  const timeoutMs = args.timeoutMs ?? 5e3;
10008
+ unrealEditorSupervisor.noteUnrealActivity();
8054
10009
  try {
8055
10010
  const response = await types.sendUnrealMcpTcpCommand({ type: "stop_play_in_editor", params: {}, timeoutMs });
10011
+ unrealEditorSupervisor.noteUnrealReachable();
8056
10012
  return {
8057
10013
  content: [
8058
10014
  { type: "text", text: "Requested Stop Play In Editor (PIE)." },
@@ -8070,6 +10026,48 @@ ${String(st.stdout || "").trim()}`
8070
10026
  };
8071
10027
  }
8072
10028
  }));
10029
+ mcp.registerTool("unreal_smoke_test", {
10030
+ title: "Unreal Smoke Test (PIE + Screenshot Validation)",
10031
+ description: 'Evidence-first "did it run?": ensures PIE is stopped, starts PIE in a new window, waits briefly, captures a validation screenshot to Saved/Screenshots/Flockbay, stops PIE, then uploads the screenshot to the current session.',
10032
+ inputSchema: {
10033
+ uprojectPath: z.z.string().describe("Absolute path to the .uproject file (used to locate Saved/Screenshots/Flockbay for evidence upload)."),
10034
+ stabilizeMs: z.z.number().int().positive().optional().describe("Time to wait before taking screenshot (default 1500)."),
10035
+ timeoutMs: z.z.number().int().positive().optional().describe("Socket timeout in ms for each step (default 30000)."),
10036
+ stopIfPlaying: z.z.boolean().optional().describe("If PIE is already running, stop it first (default true).")
10037
+ }
10038
+ }, async (args) => runWithMcpToolCard("unreal_smoke_test", args, async () => {
10039
+ const uprojectPath = typeof args?.uprojectPath === "string" ? String(args.uprojectPath).trim() : "";
10040
+ const stabilizeMs = typeof args?.stabilizeMs === "number" ? args.stabilizeMs : void 0;
10041
+ const timeoutMs = typeof args?.timeoutMs === "number" ? args.timeoutMs : void 0;
10042
+ const stopIfPlaying = typeof args?.stopIfPlaying === "boolean" ? args.stopIfPlaying : void 0;
10043
+ if (!uprojectPath || !uprojectPath.toLowerCase().endsWith(".uproject") || !path.isAbsolute(uprojectPath)) {
10044
+ return {
10045
+ content: [{ type: "text", text: `Invalid uprojectPath (must be an absolute path to *.uproject): ${String(uprojectPath)}` }],
10046
+ isError: true
10047
+ };
10048
+ }
10049
+ if (!fs.existsSync(uprojectPath)) {
10050
+ return { content: [{ type: "text", text: `uprojectPath not found: ${uprojectPath}` }], isError: true };
10051
+ }
10052
+ unrealEditorSupervisor.noteUnrealActivity();
10053
+ const result = await runUnrealSmokeTest({ uprojectPath, stabilizeMs, timeoutMs, stopIfPlaying });
10054
+ let viewsPayload = [];
10055
+ if (result.screenshotPath && typeof result.screenshotPath === "string" && fs.existsSync(result.screenshotPath)) {
10056
+ const uploaded = await uploadScreenshotViewsForSession({
10057
+ sessionId: client.sessionId,
10058
+ token: client.getAuthToken(),
10059
+ views: [{ id: "smoke_test", path: result.screenshotPath }]
10060
+ });
10061
+ viewsPayload = uploaded.map((u) => ({ ...u, id: u.id || "smoke_test" }));
10062
+ }
10063
+ const payload = { ...result, ...viewsPayload.length ? { views: viewsPayload } : {} };
10064
+ const content = [
10065
+ { type: "text", text: result.ok ? "Smoke test succeeded." : "Smoke test failed." },
10066
+ ...result.screenshotPath ? [{ type: "text", text: `Screenshot: ${result.screenshotPath}` }] : [],
10067
+ { type: "text", text: JSON.stringify(payload, null, 2) }
10068
+ ];
10069
+ return { content, ...viewsPayload.length ? { views: viewsPayload } : {}, isError: !result.ok };
10070
+ }));
8073
10071
  mcp.registerTool("unreal_mechanic_run", {
8074
10072
  title: "Unreal Mechanic Builder (Dash MVP)",
8075
10073
  description: "Create a project-safe mechanic test map (Parallel mode) and prove it works with Flockbay screenshot evidence. V1 supports dash only.",
@@ -8231,13 +10229,48 @@ Fix: ${res.hint}` : "";
8231
10229
  "read_images",
8232
10230
  "unreal_headless_screenshot",
8233
10231
  "unreal_latest_screenshots",
10232
+ "unreal_editor_launch",
10233
+ "unreal_build_project",
8234
10234
  "unreal_mcp_command",
10235
+ "unreal_mcp_list_capabilities",
10236
+ "unreal_mcp_get_command_schema",
10237
+ "unreal_mcp_search_blueprint_functions",
10238
+ "unreal_mcp_resolve_blueprint_function",
10239
+ "unreal_mcp_search_assets",
10240
+ "unreal_mcp_get_asset_info",
10241
+ "unreal_mcp_list_asset_packs",
10242
+ "unreal_mcp_place_asset",
10243
+ "unreal_mcp_get_editor_context",
10244
+ "unreal_mcp_get_player_context",
10245
+ "unreal_mcp_raycast_from_camera",
10246
+ "unreal_mcp_raycast_down",
10247
+ "unreal_mcp_get_actor_transform",
10248
+ "unreal_mcp_get_actor_bounds",
10249
+ "unreal_mcp_create_landscape",
10250
+ "unreal_mcp_map_check",
10251
+ "unreal_mcp_compile_blueprints_all",
10252
+ "unreal_mcp_save_all",
10253
+ "unreal_place_asset_relative",
10254
+ "unreal_spawn_actor_relative",
8235
10255
  "unreal_mcp_get_actors_in_level",
10256
+ "unreal_mcp_get_play_in_editor_status",
10257
+ "unreal_mcp_editor_health",
10258
+ "unreal_mcp_play_in_editor",
10259
+ "unreal_mcp_play_in_editor_windowed",
10260
+ "unreal_mcp_stop_play_in_editor",
10261
+ "unreal_smoke_test",
8236
10262
  "unreal_fast_preview",
8237
10263
  "unreal_mechanic_run"
8238
10264
  ],
10265
+ unreal: {
10266
+ onIssue: (handler2) => {
10267
+ unrealIssueEmitter.on("issue", handler2);
10268
+ return () => unrealIssueEmitter.off("issue", handler2);
10269
+ }
10270
+ },
8239
10271
  stop: () => {
8240
10272
  types.logger.debug("[flockbayMCP] Stopping server");
10273
+ unrealEditorSupervisor.stop();
8241
10274
  mcp.close();
8242
10275
  server.close();
8243
10276
  }
@@ -8462,8 +10495,20 @@ async function runClaude(credentials, options = {}) {
8462
10495
  lifecycleStateSince: Date.now(),
8463
10496
  flavor: "claude"
8464
10497
  };
8465
- const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
8466
- types.logger.debug(`Session created: ${response.id}`);
10498
+ const response = options.sessionId ? await api.getSessionById(options.sessionId) : await api.getOrCreateSession({ tag: sessionTag, metadata, state });
10499
+ types.logger.debug(`${options.sessionId ? "Session attached" : "Session created"}: ${response.id}`);
10500
+ const session = api.sessionSyncClient(response);
10501
+ if (options.sessionId) {
10502
+ session.updateMetadata((currentMetadata) => ({
10503
+ ...currentMetadata,
10504
+ ...metadata,
10505
+ // Preserve user-facing fields set by other clients.
10506
+ name: currentMetadata?.name,
10507
+ summary: currentMetadata?.summary
10508
+ }));
10509
+ }
10510
+ await session.connectAndWait(15e3);
10511
+ session.keepAlive(false, options.startingMode === "remote" ? "remote" : "local");
8467
10512
  try {
8468
10513
  types.logger.debug(`[START] Reporting session ${response.id} to daemon`);
8469
10514
  const result = await notifyDaemonSessionStarted(response.id, metadata);
@@ -8478,7 +10523,7 @@ async function runClaude(credentials, options = {}) {
8478
10523
  extractSDKMetadataAsync(async (sdkMetadata) => {
8479
10524
  types.logger.debug("[start] SDK metadata extracted, updating session:", sdkMetadata);
8480
10525
  try {
8481
- api.sessionSyncClient(response).updateMetadata((currentMetadata) => ({
10526
+ session.updateMetadata((currentMetadata) => ({
8482
10527
  ...currentMetadata,
8483
10528
  tools: sdkMetadata.tools,
8484
10529
  slashCommands: sdkMetadata.slashCommands
@@ -8488,7 +10533,6 @@ async function runClaude(credentials, options = {}) {
8488
10533
  types.logger.debug("[start] Failed to update session metadata:", error);
8489
10534
  }
8490
10535
  });
8491
- const session = api.sessionSyncClient(response);
8492
10536
  const elicitationHub = new ElicitationHub();
8493
10537
  const flockbayServer = await startFlockbayServer(session, { elicitationHub });
8494
10538
  types.logger.debug(`[START] Flockbay MCP server started at ${flockbayServer.url}`);
@@ -8840,20 +10884,45 @@ async function handleAuthCommand(args) {
8840
10884
  return;
8841
10885
  }
8842
10886
  switch (subcommand) {
8843
- case "login":
8844
- await handleAuthLogin(args.slice(1));
8845
- break;
8846
- case "logout":
8847
- await handleAuthLogout();
8848
- break;
8849
- case "show-backup":
8850
- case "show_backup":
8851
- case "backup":
8852
- await handleAuthShowBackup();
8853
- break;
8854
- case "status":
8855
- await handleAuthStatus();
8856
- break;
10887
+ case "login": {
10888
+ const force = args.includes("--force") || args.includes("-f");
10889
+ if (force) {
10890
+ try {
10891
+ await stopDaemon();
10892
+ } catch {
10893
+ }
10894
+ await types.clearCredentials();
10895
+ if (args.includes("--clear-machine-id")) {
10896
+ await types.clearMachineId();
10897
+ }
10898
+ }
10899
+ await loginWithClerkAndPairMachine();
10900
+ return;
10901
+ }
10902
+ case "logout": {
10903
+ try {
10904
+ await stopDaemon();
10905
+ } catch {
10906
+ }
10907
+ await types.clearCredentials();
10908
+ await types.clearMachineId();
10909
+ console.log(chalk.green("\u2713 Logged out (profile reset)"));
10910
+ return;
10911
+ }
10912
+ case "status": {
10913
+ const auth = await types.readCredentials();
10914
+ const settings = await types.readSettings();
10915
+ const daemon = await types.readDaemonState();
10916
+ console.log(chalk.bold("\nAuthentication Status\n"));
10917
+ console.log(chalk.gray(`Profile: ${types.configuration.profile}`));
10918
+ console.log(chalk.gray(`Server: ${types.configuration.serverUrl}`));
10919
+ console.log(chalk.gray(`Web app: ${types.configuration.webappUrl}`));
10920
+ console.log(chalk.gray(`Machine: ${String(settings?.machineId || "missing")}`));
10921
+ console.log(chalk.gray(`Workspace:${auth?.orgId ? ` ${auth.orgId}` : " missing"}`));
10922
+ console.log(chalk.gray(`Auth: ${auth?.machineToken ? "paired" : "not paired"}`));
10923
+ console.log(chalk.gray(`Daemon: ${daemon?.pid ? `pid=${daemon.pid} port=${daemon.httpPort}` : "not running"}`));
10924
+ return;
10925
+ }
8857
10926
  default:
8858
10927
  console.error(chalk.red(`Unknown auth subcommand: ${subcommand}`));
8859
10928
  showAuthHelp();
@@ -8862,186 +10931,14 @@ async function handleAuthCommand(args) {
8862
10931
  }
8863
10932
  function showAuthHelp() {
8864
10933
  console.log(`
8865
- ${chalk.bold("flockbay auth")} - Authentication management
10934
+ ${chalk.bold("flockbay auth")} - Authentication management (Clerk-based)
8866
10935
 
8867
10936
  ${chalk.bold("Usage:")}
8868
- flockbay auth login [--force] [--mobile] Authenticate with Flockbay
8869
- flockbay auth logout Remove authentication and machine data
8870
- flockbay auth status Show authentication status
8871
- flockbay auth show-backup Display backup key for mobile/web clients
8872
- flockbay auth help Show this help message
8873
-
8874
- ${chalk.bold("Options:")}
8875
- --force Clear credentials, machine ID, and stop daemon before re-auth
8876
- --mobile Authenticate by scanning a QR code (advanced)
8877
- --web Authenticate via browser login (default)
8878
- `);
8879
- }
8880
- async function handleAuthLogin(args) {
8881
- const forceAuth = args.includes("--force") || args.includes("-f");
8882
- const quiet = args.includes("--quiet") || args.includes("--no-summary");
8883
- const preferMobile = args.includes("--mobile");
8884
- const preferWeb = args.includes("--web");
8885
- if (preferMobile && preferWeb) {
8886
- console.error(chalk.red("Choose only one authentication method: --mobile or --web"));
8887
- process.exit(1);
8888
- }
8889
- const authMethod = preferMobile ? "mobile" : "web";
8890
- if (forceAuth) {
8891
- if (!quiet) {
8892
- console.log(chalk.yellow("Force authentication requested."));
8893
- console.log(chalk.gray("This will:"));
8894
- console.log(chalk.gray(" \u2022 Clear existing credentials"));
8895
- console.log(chalk.gray(" \u2022 Clear machine ID"));
8896
- console.log(chalk.gray(" \u2022 Stop daemon if running"));
8897
- console.log(chalk.gray(" \u2022 Re-authenticate and register machine\n"));
8898
- }
8899
- try {
8900
- types.logger.debug("Stopping daemon for force auth...");
8901
- await stopDaemon();
8902
- if (!quiet) console.log(chalk.gray("\u2713 Stopped daemon"));
8903
- } catch (error) {
8904
- types.logger.debug("Daemon was not running or failed to stop:", error);
8905
- }
8906
- await types.clearCredentials();
8907
- if (!quiet) console.log(chalk.gray("\u2713 Cleared credentials"));
8908
- await types.clearMachineId();
8909
- if (!quiet) console.log(chalk.gray("\u2713 Cleared machine ID"));
8910
- if (!quiet) console.log("");
8911
- }
8912
- if (!forceAuth) {
8913
- const existingCreds = await types.readCredentials();
8914
- const settings = await types.readSettings();
8915
- if (existingCreds && settings?.machineId) {
8916
- if (!quiet) {
8917
- console.log(chalk.green("\u2713 Already authenticated"));
8918
- console.log(chalk.gray(` Machine ID: ${settings.machineId}`));
8919
- console.log(chalk.gray(` Host: ${os.hostname()}`));
8920
- console.log(chalk.gray(` Use 'flockbay auth login --force' to re-authenticate`));
8921
- }
8922
- return;
8923
- } else if (existingCreds && !settings?.machineId) {
8924
- if (!quiet) {
8925
- console.log(chalk.yellow("\u26A0\uFE0F Credentials exist but machine ID is missing"));
8926
- console.log(chalk.gray(" This can happen if --auth flag was used previously"));
8927
- console.log(chalk.gray(" Fixing by setting up machine...\n"));
8928
- }
8929
- }
8930
- }
8931
- try {
8932
- const result = await authAndSetupMachineIfNeeded({ authMethod });
8933
- if (!quiet) {
8934
- console.log(chalk.green("\n\u2713 Authentication successful"));
8935
- console.log(chalk.gray(` Machine ID: ${result.machineId}`));
8936
- console.log(chalk.gray(` Server: ${types.configuration.serverUrl}`));
8937
- console.log(chalk.gray(` Web app: ${types.configuration.webappUrl}`));
8938
- console.log(
8939
- chalk.gray(
8940
- `
8941
- Next: run ${chalk.bold("flockbay start")} to start the daemon (this is what makes your computer appear as connected in the app).`
8942
- )
8943
- );
8944
- }
8945
- } catch (error) {
8946
- console.error(chalk.red("Authentication failed:"), error instanceof Error ? error.message : "Unknown error");
8947
- process.exit(1);
8948
- }
8949
- }
8950
- async function handleAuthLogout() {
8951
- const dataDir = types.configuration.flockbayHomeDir;
8952
- const credentials = await types.readCredentials();
8953
- if (!credentials) {
8954
- console.log(chalk.yellow("Not currently authenticated"));
8955
- return;
8956
- }
8957
- console.log(chalk.blue("This will log you out of Flockbay"));
8958
- console.log(chalk.yellow("\u26A0\uFE0F You will need to re-authenticate to use Flockbay again"));
8959
- const rl = node_readline.createInterface({
8960
- input: process.stdin,
8961
- output: process.stdout
8962
- });
8963
- const answer = await new Promise((resolve) => {
8964
- rl.question(chalk.yellow("Are you sure you want to log out? (y/N): "), resolve);
8965
- });
8966
- rl.close();
8967
- if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
8968
- try {
8969
- try {
8970
- await stopDaemon();
8971
- console.log(chalk.gray("Stopped daemon"));
8972
- } catch (err) {
8973
- console.error("[auth logout] Failed to stop daemon during logout", err);
8974
- console.log(chalk.yellow("Warning: failed to stop daemon (continuing logout)."));
8975
- }
8976
- if (fs.existsSync(dataDir)) {
8977
- fs.rmSync(dataDir, { recursive: true, force: true });
8978
- }
8979
- console.log(chalk.green("\u2713 Successfully logged out"));
8980
- console.log(chalk.gray(' Run "flockbay auth login" to authenticate again'));
8981
- } catch (error) {
8982
- throw new Error(`Failed to logout: ${error instanceof Error ? error.message : "Unknown error"}`);
8983
- }
8984
- } else {
8985
- console.log(chalk.blue("Logout cancelled"));
8986
- }
8987
- }
8988
- async function handleAuthShowBackup() {
8989
- const accessKeyPath = types.configuration.privateKeyFile;
8990
- if (!fs.existsSync(accessKeyPath)) {
8991
- console.log(chalk.red("\u2717 Not authenticated"));
8992
- console.log(chalk.gray(' Run "flockbay auth login" to authenticate'));
8993
- process.exit(1);
8994
- }
8995
- let parsed;
8996
- try {
8997
- parsed = JSON.parse(fs.readFileSync(accessKeyPath, "utf8"));
8998
- } catch {
8999
- console.error(chalk.red("Backup key unavailable: failed to parse key file."));
9000
- process.exit(1);
9001
- }
9002
- const machineKeyB64 = parsed?.encryption?.machineKey;
9003
- if (!machineKeyB64 || typeof machineKeyB64 !== "string") {
9004
- console.error(chalk.red("Backup key unavailable: encryption.machineKey missing in key file."));
9005
- process.exit(1);
9006
- }
9007
- const buf = Buffer.from(machineKeyB64, "base64");
9008
- const key = buf.toString("base64url");
9009
- process.stdout.write(`${key}
10937
+ flockbay auth login [--force] [--clear-machine-id] Sign in and pair this machine to a workspace
10938
+ flockbay auth logout Clear local auth + machine id for this profile
10939
+ flockbay auth status Show current status
9010
10940
  `);
9011
10941
  }
9012
- async function handleAuthStatus() {
9013
- const credentials = await types.readCredentials();
9014
- const settings = await types.readSettings();
9015
- console.log(chalk.bold("\nAuthentication Status\n"));
9016
- if (!credentials) {
9017
- console.log(chalk.red("\u2717 Not authenticated"));
9018
- console.log(chalk.gray(' Run "flockbay auth login" to authenticate'));
9019
- return;
9020
- }
9021
- console.log(chalk.green("\u2713 Authenticated"));
9022
- const tokenPreview = credentials.token.substring(0, 30) + "...";
9023
- console.log(chalk.gray(` Token: ${tokenPreview}`));
9024
- if (settings?.machineId) {
9025
- console.log(chalk.green("\u2713 Machine registered"));
9026
- console.log(chalk.gray(` Machine ID: ${settings.machineId}`));
9027
- console.log(chalk.gray(` Host: ${os.hostname()}`));
9028
- } else {
9029
- console.log(chalk.yellow("\u26A0\uFE0F Machine not registered"));
9030
- console.log(chalk.gray(' Run "flockbay auth login --force" to fix this'));
9031
- }
9032
- console.log(chalk.gray(`
9033
- Data directory: ${types.configuration.flockbayHomeDir}`));
9034
- try {
9035
- const running = await checkIfDaemonRunningAndCleanupStaleState();
9036
- if (running) {
9037
- console.log(chalk.green("\u2713 Daemon running"));
9038
- } else {
9039
- console.log(chalk.gray("\u2717 Daemon not running"));
9040
- }
9041
- } catch {
9042
- console.log(chalk.gray("\u2717 Daemon not running"));
9043
- }
9044
- }
9045
10942
 
9046
10943
  const CLIENT_ID$2 = "app_EMoamEEZ73f0CkXaXp7hrann";
9047
10944
  const AUTH_BASE_URL = "https://auth.openai.com";
@@ -9667,6 +11564,15 @@ function looksLikeMachineAuthMismatch(logTail) {
9667
11564
  const hasMachinesEndpoint = /\/v1\/machines\b/i.test(t);
9668
11565
  return has401 && hasMachinesEndpoint;
9669
11566
  }
11567
+ function looksLikeServerUnreachable(logTail) {
11568
+ const t = String(logTail || "");
11569
+ if (!t) return null;
11570
+ const codeMatch = t.match(/\b(ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT)\b/) || t.match(/connect\s+(ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT)\b/i);
11571
+ const code = codeMatch?.[1] || codeMatch?.[0] || null;
11572
+ if (!code) return null;
11573
+ const url = t.match(/"url"\s*:\s*"([^"]+)"/)?.[1] ?? null;
11574
+ return { code: String(code), url };
11575
+ }
9670
11576
  async function promptYesNo(question, { defaultYes }) {
9671
11577
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
9672
11578
  if (!isInteractive) return false;
@@ -9689,10 +11595,135 @@ async function reauthForCurrentServerKeepingMachineId() {
9689
11595
  } catch {
9690
11596
  }
9691
11597
  await types.clearCredentials();
9692
- await authAndSetupMachineIfNeeded();
11598
+ await loginWithClerkAndPairMachine();
11599
+ }
11600
+ function openUrlBestEffort(url) {
11601
+ const u = String(url || "").trim();
11602
+ if (!u) return;
11603
+ if (process.env.FLOCKBAY_NO_OPEN === "1") return;
11604
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
11605
+ if (!isInteractive) return;
11606
+ try {
11607
+ if (process.platform === "darwin") {
11608
+ const p2 = node_child_process.spawn("open", [u], { detached: true, stdio: "ignore" });
11609
+ p2.unref();
11610
+ return;
11611
+ }
11612
+ if (process.platform === "win32") {
11613
+ const p2 = node_child_process.spawn("cmd", ["/c", "start", "", u], { detached: true, stdio: "ignore" });
11614
+ p2.unref();
11615
+ return;
11616
+ }
11617
+ const p = node_child_process.spawn("xdg-open", [u], { detached: true, stdio: "ignore" });
11618
+ p.unref();
11619
+ } catch {
11620
+ }
11621
+ }
11622
+ async function fetchDaemonStatus() {
11623
+ const state = await types.readDaemonState().catch(() => null);
11624
+ if (!state?.httpPort || !state?.pid) return null;
11625
+ try {
11626
+ process.kill(state.pid, 0);
11627
+ } catch {
11628
+ return null;
11629
+ }
11630
+ try {
11631
+ const res = await fetch(`http://127.0.0.1:${state.httpPort}/status`, {
11632
+ method: "POST",
11633
+ headers: { "Content-Type": "application/json" },
11634
+ body: "{}",
11635
+ signal: AbortSignal.timeout(1500)
11636
+ });
11637
+ if (!res.ok) return null;
11638
+ return await res.json().catch(() => null);
11639
+ } catch {
11640
+ return null;
11641
+ }
11642
+ }
11643
+ async function waitForDaemonConnected(timeoutMs) {
11644
+ const deadline = Date.now() + Math.max(0, timeoutMs);
11645
+ while (Date.now() < deadline) {
11646
+ const status = await fetchDaemonStatus();
11647
+ const connected = Boolean(status?.connection?.connected);
11648
+ if (connected) return status;
11649
+ await new Promise((r) => setTimeout(r, 250));
11650
+ }
11651
+ return fetchDaemonStatus();
9693
11652
  }
9694
11653
  async function startDaemonDetachedOrExit(opts) {
9695
- const child = spawnFlockbayCLI(["daemon", "start-sync"], {
11654
+ const alreadyRunning = await checkIfDaemonRunningAndCleanupStaleState().catch(() => false);
11655
+ if (alreadyRunning) {
11656
+ const versionMatches = await isDaemonRunningCurrentCliVersion().catch(() => true);
11657
+ if (!versionMatches) {
11658
+ await stopDaemon();
11659
+ } else {
11660
+ const status = await waitForDaemonConnected(5e3);
11661
+ const connected = Boolean(status?.connection?.connected);
11662
+ const lastUpsertStatus = Number.isFinite(Number(status?.connection?.lastHttpUpsertStatus)) ? Number(status.connection.lastHttpUpsertStatus) : null;
11663
+ const lastConnectError = typeof status?.connection?.lastConnectError === "string" ? status.connection.lastConnectError : "";
11664
+ if (connected) {
11665
+ const auth = await types.readCredentials().catch(() => null);
11666
+ const settings = await types.readSettings().catch(() => null);
11667
+ const daemon = await types.readDaemonState().catch(() => null);
11668
+ const desiredOrgId = auth?.orgId ? String(auth.orgId).trim() : "";
11669
+ const desiredMachineId = settings?.machineId ? String(settings.machineId).trim() : "";
11670
+ const daemonOrgId = typeof status?.orgId === "string" ? status.orgId.trim() : "";
11671
+ const daemonMachineId = typeof status?.machineId === "string" ? status.machineId.trim() : "";
11672
+ const daemonServerUrl = typeof status?.serverUrl === "string" ? status.serverUrl.trim() : "";
11673
+ const desiredServerUrl = String(types.configuration.serverUrl || "").trim();
11674
+ const orgMismatch = desiredOrgId && daemonOrgId && desiredOrgId !== daemonOrgId;
11675
+ const machineMismatch = desiredMachineId && daemonMachineId && desiredMachineId !== daemonMachineId;
11676
+ const serverMismatch = desiredServerUrl && daemonServerUrl && desiredServerUrl !== daemonServerUrl;
11677
+ if ((orgMismatch || machineMismatch || serverMismatch) && !opts?.restartAttempted) {
11678
+ console.error("");
11679
+ console.error(chalk.yellow("Daemon is running with different settings than the current CLI profile."));
11680
+ if (serverMismatch) console.error(chalk.gray(`Daemon server: ${daemonServerUrl || "unknown"}`));
11681
+ if (desiredServerUrl) console.error(chalk.gray(`CLI server: ${desiredServerUrl}`));
11682
+ if (orgMismatch) console.error(chalk.gray(`Daemon org: ${daemonOrgId || "unknown"}`));
11683
+ if (desiredOrgId) console.error(chalk.gray(`CLI org: ${desiredOrgId}`));
11684
+ if (machineMismatch) console.error(chalk.gray(`Daemon machine: ${daemonMachineId || "unknown"}`));
11685
+ if (desiredMachineId) console.error(chalk.gray(`CLI machine: ${desiredMachineId}`));
11686
+ console.error(chalk.gray("Restarting daemon to apply current login/pairing..."));
11687
+ await stopDaemon();
11688
+ await startDaemonDetachedOrExit({ ...opts, restartAttempted: true });
11689
+ return;
11690
+ }
11691
+ console.log(chalk.bold("\nFlockbay ready\n"));
11692
+ console.log(chalk.gray(`Profile: ${types.configuration.profile}`));
11693
+ console.log(chalk.gray(`Server: ${types.configuration.serverUrl}`));
11694
+ console.log(chalk.gray(`Web app: ${types.configuration.webappUrl}`));
11695
+ console.log(chalk.gray(`Machine: ${String(settings?.machineId || "missing")}`));
11696
+ console.log(chalk.gray(`Workspace:${auth?.orgId ? ` ${auth.orgId}` : " missing"}`));
11697
+ console.log(chalk.gray(`Daemon: ${daemon?.pid ? `pid=${daemon.pid} port=${daemon.httpPort}` : "not running"}`));
11698
+ console.log("");
11699
+ openUrlBestEffort(types.configuration.webappUrl);
11700
+ process.exit(0);
11701
+ }
11702
+ const authMismatch = lastUpsertStatus === 401 || /unauthorized/i.test(lastConnectError);
11703
+ if (authMismatch && !opts?.reauthAttempted) {
11704
+ console.error("");
11705
+ console.error(chalk.yellow("Your saved CLI token was rejected by the server (401/unauthorized)."));
11706
+ console.error(chalk.gray("This is common in local dev if the backend store was reset/rebuilt."));
11707
+ console.error("");
11708
+ const shouldReauth = await promptYesNo("Re-authenticate now and retry?", { defaultYes: true });
11709
+ if (shouldReauth) {
11710
+ await reauthForCurrentServerKeepingMachineId();
11711
+ await startDaemonDetachedOrExit({ reauthAttempted: true });
11712
+ return;
11713
+ }
11714
+ process.exit(1);
11715
+ }
11716
+ console.error("");
11717
+ console.error(chalk.red("Daemon is running but not connected to the server."));
11718
+ if (typeof status?.connection?.lastHttpUpsertError === "string" && status.connection.lastHttpUpsertError.trim()) {
11719
+ console.error(chalk.gray(`Last upsert error: ${status.connection.lastHttpUpsertError.trim()}`));
11720
+ }
11721
+ if (lastConnectError) console.error(chalk.gray(`Last connect error: ${lastConnectError}`));
11722
+ console.error(chalk.gray("Tip: if the backend is restarting, wait a moment and re-run `flockbay start`."));
11723
+ process.exit(1);
11724
+ }
11725
+ }
11726
+ const child = spawnFlockbayCLI(["daemon", "start-sync", "--profile", types.configuration.profile], {
9696
11727
  detached: true,
9697
11728
  stdio: "ignore",
9698
11729
  env: process.env
@@ -9707,7 +11738,28 @@ async function startDaemonDetachedOrExit(opts) {
9707
11738
  await new Promise((resolve) => setTimeout(resolve, 100));
9708
11739
  }
9709
11740
  if (started) {
9710
- console.log("Daemon started successfully");
11741
+ const status = await waitForDaemonConnected(5e3);
11742
+ if (!Boolean(status?.connection?.connected)) {
11743
+ const lastUpsertError = typeof status?.connection?.lastHttpUpsertError === "string" ? status.connection.lastHttpUpsertError.trim() : "";
11744
+ const lastConnectError = typeof status?.connection?.lastConnectError === "string" ? status.connection.lastConnectError.trim() : "";
11745
+ console.error(chalk.red("Daemon started, but failed to connect to the server."));
11746
+ if (lastUpsertError) console.error(chalk.gray(`Last upsert error: ${lastUpsertError}`));
11747
+ if (lastConnectError) console.error(chalk.gray(`Last connect error: ${lastConnectError}`));
11748
+ console.error(chalk.gray("Tip: if the backend is restarting, wait a moment and re-run `flockbay start`."));
11749
+ process.exit(1);
11750
+ }
11751
+ const auth = await types.readCredentials().catch(() => null);
11752
+ const settings = await types.readSettings().catch(() => null);
11753
+ const daemon = await types.readDaemonState().catch(() => null);
11754
+ console.log(chalk.bold("\nFlockbay ready\n"));
11755
+ console.log(chalk.gray(`Profile: ${types.configuration.profile}`));
11756
+ console.log(chalk.gray(`Server: ${types.configuration.serverUrl}`));
11757
+ console.log(chalk.gray(`Web app: ${types.configuration.webappUrl}`));
11758
+ console.log(chalk.gray(`Machine: ${String(settings?.machineId || "missing")}`));
11759
+ console.log(chalk.gray(`Workspace:${auth?.orgId ? ` ${auth.orgId}` : " missing"}`));
11760
+ console.log(chalk.gray(`Daemon: ${daemon?.pid ? `pid=${daemon.pid} port=${daemon.httpPort}` : "not running"}`));
11761
+ console.log("");
11762
+ openUrlBestEffort(types.configuration.webappUrl);
9711
11763
  process.exit(0);
9712
11764
  } else {
9713
11765
  const latest = await types.getLatestDaemonLog();
@@ -9728,6 +11780,19 @@ async function startDaemonDetachedOrExit(opts) {
9728
11780
  console.error(chalk.gray("Fix: run `flockbay start` to re-authenticate and restart the daemon."));
9729
11781
  process.exit(1);
9730
11782
  }
11783
+ const unreachable = looksLikeServerUnreachable(logTail);
11784
+ if (unreachable) {
11785
+ console.error("");
11786
+ console.error(chalk.red(`Cannot reach server while starting daemon (${unreachable.code}).`));
11787
+ console.error(chalk.gray(`Server: ${types.configuration.serverUrl}`));
11788
+ if (unreachable.url) console.error(chalk.gray(`Failed request: ${unreachable.url}`));
11789
+ if (/^https?:\/\/(127\.0\.0\.1|localhost)\b/i.test(types.configuration.serverUrl)) {
11790
+ console.error("");
11791
+ console.error(chalk.gray("Local dev fix: start the backend first, then re-run `flockbay start`:"));
11792
+ console.error(chalk.gray(" cd backend && npm run dev"));
11793
+ }
11794
+ process.exit(1);
11795
+ }
9731
11796
  console.error("Failed to start daemon");
9732
11797
  console.error("Tip: run `flockbay auth status` to confirm you are logged in.");
9733
11798
  process.exit(1);
@@ -9738,10 +11803,10 @@ function showStartHelp() {
9738
11803
  ${chalk.bold("flockbay start")} - One-command setup
9739
11804
 
9740
11805
  ${chalk.bold("Usage:")}
9741
- flockbay start [--engine-root <path>] Authenticate and start the daemon
11806
+ flockbay start [--engine-root <path>] [--profile <name>] Ensure login + daemon running
9742
11807
 
9743
11808
  ${chalk.bold("Options:")}
9744
- (start always forces a login; same as \`flockbay auth login --force\`)
11809
+ --profile <name> CLI profile (isolates server/workspace/machine state)
9745
11810
  --engine-root <path> Unreal Engine install folder (UE_5.5\u2013UE_5.7)
9746
11811
  --skip-unreal Skip Unreal bridge install
9747
11812
  `);
@@ -9753,6 +11818,10 @@ function readArgValue(args, key) {
9753
11818
  if (!value || value.startsWith("-")) return null;
9754
11819
  return value;
9755
11820
  }
11821
+ async function authAndSetupMachineIfNeeded() {
11822
+ const { auth, machineId } = await ensureMachineAuthOrLogin();
11823
+ return { credentials: auth, machineId };
11824
+ }
9756
11825
  (async () => {
9757
11826
  const invokedCwd = process.env.FLOCKBAY_INVOKED_CWD;
9758
11827
  if (invokedCwd?.trim()) {
@@ -9773,6 +11842,9 @@ function readArgValue(args, key) {
9773
11842
  args = args.slice(1);
9774
11843
  subcommand = args[0];
9775
11844
  }
11845
+ if (subcommand === "setup") {
11846
+ subcommand = "start";
11847
+ }
9776
11848
  if (!args.includes("--version")) ;
9777
11849
  if (subcommand === "doctor") {
9778
11850
  if (args[1] === "clean") {
@@ -9796,6 +11868,43 @@ function readArgValue(args, key) {
9796
11868
  process.exit(1);
9797
11869
  }
9798
11870
  return;
11871
+ } else if (subcommand === "login") {
11872
+ try {
11873
+ await loginWithClerkAndPairMachine();
11874
+ } catch (error) {
11875
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
11876
+ if (process.env.DEBUG) console.error(error);
11877
+ process.exit(1);
11878
+ }
11879
+ return;
11880
+ } else if (subcommand === "status") {
11881
+ const auth = await types.readCredentials();
11882
+ const settings = await types.readSettings();
11883
+ const daemon = await types.readDaemonState();
11884
+ console.log(chalk.bold("\nFlockbay Status\n"));
11885
+ console.log(chalk.gray(`Profile: ${types.configuration.profile}`));
11886
+ console.log(chalk.gray(`Server: ${types.configuration.serverUrl}`));
11887
+ console.log(chalk.gray(`Web app: ${types.configuration.webappUrl}`));
11888
+ console.log(chalk.gray(`Machine: ${String(settings?.machineId || "missing")}`));
11889
+ console.log(chalk.gray(`Workspace:${auth?.orgId ? ` ${auth.orgId}` : " missing"}`));
11890
+ console.log(chalk.gray(`Auth: ${auth?.machineToken ? "paired" : "not paired"}`));
11891
+ console.log(chalk.gray(`Daemon: ${daemon?.pid ? `pid=${daemon.pid} port=${daemon.httpPort}` : "not running"}`));
11892
+ process.exit(0);
11893
+ } else if (subcommand === "reset") {
11894
+ const force = args.includes("--force");
11895
+ if (!force) {
11896
+ console.error(chalk.red("Refusing to reset without --force"));
11897
+ console.error(chalk.gray("This clears local auth + machine binding for this profile."));
11898
+ process.exit(1);
11899
+ }
11900
+ try {
11901
+ await stopDaemon();
11902
+ } catch {
11903
+ }
11904
+ await types.clearCredentials();
11905
+ await types.clearMachineId();
11906
+ console.log("Reset complete");
11907
+ process.exit(0);
9799
11908
  } else if (subcommand === "connect") {
9800
11909
  try {
9801
11910
  await handleConnectCommand(args.slice(1));
@@ -9807,22 +11916,14 @@ function readArgValue(args, key) {
9807
11916
  process.exit(1);
9808
11917
  }
9809
11918
  return;
9810
- } else if (subcommand === "start" || subcommand === "setup") {
9811
- if (subcommand === "setup") {
9812
- console.log(chalk.yellow("Note: `flockbay setup` was renamed to `flockbay start`."));
9813
- console.log(chalk.gray("Running `flockbay start`...\n"));
9814
- }
11919
+ } else if (subcommand === "start") {
9815
11920
  const startArgs = args.slice(1);
9816
11921
  if (startArgs.includes("--help") || startArgs.includes("-h") || startArgs.includes("help")) {
9817
11922
  showStartHelp();
9818
11923
  return;
9819
11924
  }
9820
11925
  try {
9821
- try {
9822
- await checkIfDaemonRunningAndCleanupStaleState();
9823
- await stopDaemon();
9824
- } catch {
9825
- }
11926
+ await ensureMachineAuthOrLogin();
9826
11927
  const skipUnreal = startArgs.includes("--skip-unreal");
9827
11928
  if (!skipUnreal) {
9828
11929
  const engineRoot = readArgValue(startArgs, "--engine-root") || (process.env.UE_ENGINE_ROOT || "").trim() || (process.env.ENGINE_ROOT || "").trim() || null;
@@ -9844,7 +11945,6 @@ ${engineRoot}`, {
9844
11945
  }
9845
11946
  }
9846
11947
  }
9847
- await handleAuthCommand(["login", "--force", "--quiet"]);
9848
11948
  await startDaemonDetachedOrExit();
9849
11949
  } catch (error) {
9850
11950
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
@@ -9857,17 +11957,20 @@ ${engineRoot}`, {
9857
11957
  } else if (subcommand === "codex") {
9858
11958
  try {
9859
11959
  await chdirToNearestUprojectRootIfPresent();
9860
- const { runCodex } = await Promise.resolve().then(function () { return require('./runCodex-D3eT-TvB.cjs'); });
11960
+ const { runCodex } = await Promise.resolve().then(function () { return require('./runCodex-DuCGwO2K.cjs'); });
9861
11961
  let startedBy = void 0;
11962
+ let sessionId = void 0;
9862
11963
  for (let i = 1; i < args.length; i++) {
9863
11964
  if (args[i] === "--started-by") {
9864
11965
  startedBy = args[++i];
11966
+ } else if (args[i] === "--flockbay-session-id") {
11967
+ sessionId = args[++i];
9865
11968
  }
9866
11969
  }
9867
11970
  const {
9868
11971
  credentials
9869
11972
  } = await authAndSetupMachineIfNeeded();
9870
- await runCodex({ credentials, startedBy });
11973
+ await runCodex({ credentials, startedBy, sessionId });
9871
11974
  } catch (error) {
9872
11975
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
9873
11976
  if (process.env.DEBUG) {
@@ -9949,11 +12052,14 @@ ${engineRoot}`, {
9949
12052
  }
9950
12053
  try {
9951
12054
  await chdirToNearestUprojectRootIfPresent();
9952
- const { runGemini } = await Promise.resolve().then(function () { return require('./runGemini-CBxZp6I7.cjs'); });
12055
+ const { runGemini } = await Promise.resolve().then(function () { return require('./runGemini-B25LZ4Cw.cjs'); });
9953
12056
  let startedBy = void 0;
12057
+ let sessionId = void 0;
9954
12058
  for (let i = 1; i < args.length; i++) {
9955
12059
  if (args[i] === "--started-by") {
9956
12060
  startedBy = args[++i];
12061
+ } else if (args[i] === "--flockbay-session-id") {
12062
+ sessionId = args[++i];
9957
12063
  }
9958
12064
  }
9959
12065
  const {
@@ -9970,19 +12076,7 @@ ${engineRoot}`, {
9970
12076
  daemonProcess.unref();
9971
12077
  await new Promise((resolve) => setTimeout(resolve, 200));
9972
12078
  }
9973
- await runGemini({ credentials, startedBy });
9974
- } catch (error) {
9975
- console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
9976
- if (process.env.DEBUG) {
9977
- console.error(error);
9978
- }
9979
- process.exit(1);
9980
- }
9981
- return;
9982
- } else if (subcommand === "logout") {
9983
- console.log(chalk.yellow('Note: "logout" is deprecated. Use "flockbay auth logout" instead.\n'));
9984
- try {
9985
- await handleAuthCommand(["logout"]);
12079
+ await runGemini({ credentials, startedBy, sessionId });
9986
12080
  } catch (error) {
9987
12081
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
9988
12082
  if (process.env.DEBUG) {
@@ -10111,6 +12205,8 @@ ${chalk.bold("To clean up runaway processes:")} Use ${chalk.cyan("flockbay docto
10111
12205
  unknownArgs.push("--dangerously-skip-permissions");
10112
12206
  } else if (arg === "--started-by") {
10113
12207
  options.startedBy = args[++i];
12208
+ } else if (arg === "--flockbay-session-id") {
12209
+ options.sessionId = args[++i];
10114
12210
  } else if (arg === "--claude-env") {
10115
12211
  const envArg = args[++i];
10116
12212
  if (envArg && envArg.includes("=")) {
@@ -10295,6 +12391,7 @@ exports.applyCoordinationSideEffectsFromMcpToolResult = applyCoordinationSideEff
10295
12391
  exports.autoFinalizeCoordinationWorkItem = autoFinalizeCoordinationWorkItem;
10296
12392
  exports.buildProjectCapsule = buildProjectCapsule;
10297
12393
  exports.consumeToolQuota = consumeToolQuota;
12394
+ exports.detectScreenshotsForGate = detectScreenshotsForGate;
10298
12395
  exports.detectUnrealProject = detectUnrealProject;
10299
12396
  exports.extractUserImagesMarker = extractUserImagesMarker;
10300
12397
  exports.formatQuotaDeniedReason = formatQuotaDeniedReason;