-- picoo (single-file build)
-- Built at 2026-08-26T20:19:23Z from src/
-- Build version: 1.7
-- DO NOT EDIT — edit src/*.lua and re-run build-plugin.sh

--[[ ================= INLINED MODULES ================= ]]

--[[ === Module: Auth === ]]
local Auth = (function()
--[[
	Auth Module — forge_key storage + session token management

	Flow:
	1. User enters an API key (from the picoo.io dashboard)
	2. Plugin POSTs key + Roblox UserId to /api/forge/auth/session
	3. Server returns session_token (2hr expiry)
	4. Token used for /api/forge/bridge/poll + /api/forge/bridge/result
]]

local Auth = {}

local plugin_ref = nil
local HttpService = nil
local apiBase = ""
local pluginVersion = nil
local sessionToken = nil
local sessionExpiry = 0
-- Version-banner ride-along (§2): the /auth response tells the plugin the
-- latest published version + where to grab it. Manual-paste users see a
-- banner; Store users get native auto-update. No runtime self-update code.
local latestPluginVersion = nil
local updateUrl = nil

local SETTING_KEY = "Picoo_ApiKey"  -- distinct from legacy plugin
local SETTING_TOKEN = "Picoo_SessionToken"
local SETTING_TOKEN_EXPIRY = "Picoo_SessionExpiry"
local BACKUP_FOLDER = "PicooConfig"
local BACKUP_VALUE = "ApiKey"

function Auth.init(pluginInstance, httpService, base, version)
	plugin_ref = pluginInstance
	HttpService = httpService
	apiBase = base
	-- Plain string like "1.0.9". Server stores it on forge_sessions.plugin_version
	-- so support/admin can tell who's on an outdated build without asking.
	pluginVersion = version

	-- Restore cached session_token across plugin reloads. The server enforces
	-- "1 active session per forge_key" — if we always re-auth on load we
	-- deactivate the previous session, kill in-flight bridge_commands, and
	-- pay an extra HTTP roundtrip. Use the cached token until it actually
	-- returns 401, only THEN re-auth.
	local cachedToken = plugin_ref:GetSetting(SETTING_TOKEN)
	local cachedExpiry = plugin_ref:GetSetting(SETTING_TOKEN_EXPIRY)
	if cachedToken and cachedExpiry and tonumber(cachedExpiry) and tonumber(cachedExpiry) > os.time() then
		sessionToken = cachedToken
		sessionExpiry = tonumber(cachedExpiry)
	end
end

-- SECURITY: ServerStorage backup removed (was a foot-gun). The
-- StringValue persisted into the user's place file when they saved
-- with the plugin installed; if they then PUBLISHED that place,
-- decompilation tools could read the key out of the place file.
-- plugin:SetSetting / GetSetting is per-Studio-install and never
-- enters the place data, which is the right scope for a credential.
--
-- Migration: on first load with this build, scrub any leftover backup
-- node from previous installs so old place files don't keep leaking
-- the key forward.
local function scrubLegacyBackup()
	pcall(function()
		local ss = game:GetService("ServerStorage")
		local folder = ss:FindFirstChild(BACKUP_FOLDER)
		if folder then folder:Destroy() end
	end)
end
scrubLegacyBackup()

function Auth.getApiKey()
	return plugin_ref:GetSetting(SETTING_KEY) or ""
end

-- Strip whitespace + invisible characters that sneak in via clipboard paste.
-- Telemetry showed users hitting invalid_key dozens of times because the
-- TextBox preserved a trailing newline / nbsp from copy-paste. Server side
-- also trims (defense in depth), but trimming here means the saved value
-- in plugin settings is canonical too.
local function trimKey(s)
	if type(s) ~= "string" then return "" end
	-- Remove ASCII whitespace + tab/newline/cr + nbsp (0xA0) + BOM (U+FEFF).
	local out = s:gsub("[%s%z]", ""):gsub("\194\160", ""):gsub("\239\187\191", "")
	-- ...and the zero-width / directional marks a clipboard carries invisibly:
	-- U+200B-U+200F and U+2060. Only U+200B was handled before, so a key with
	-- a zero-width joiner in it looked identical to a correct one on screen and
	-- missed every hash lookup on the server (2026-08-26).
	for _, cp in ipairs({ "\226\128\139", "\226\128\140", "\226\128\141", "\226\128\142", "\226\128\143", "\226\129\160" }) do
		out = out:gsub(cp, "")
	end
	-- Quotes people copy along with the value they meant.
	out = out:gsub('^["\'`]+', ""):gsub('["\'`]+$', "")
	-- NOT stripped, deliberately: the U+2022 bullets of a masked key. A key
	-- with its middle replaced by dots is a different string, not a damaged
	-- one, and silently deleting them would hand an 8-character prefix to the
	-- server as if it were a credential. The server names that case instead.
	return out
end

function Auth.setApiKey(key)
	plugin_ref:SetSetting(SETTING_KEY, trimKey(key))
end

function Auth.clearApiKey()
	plugin_ref:SetSetting(SETTING_KEY, "")
end

function Auth.getSessionToken()
	if not sessionToken or os.time() >= sessionExpiry then
		sessionToken = nil
		return nil
	end
	return sessionToken
end

function Auth.clearSession()
	sessionToken = nil
	sessionExpiry = 0
	pcall(function()
		plugin_ref:SetSetting(SETTING_TOKEN, "")
		plugin_ref:SetSetting(SETTING_TOKEN_EXPIRY, 0)
	end)
end

function Auth.authenticate(apiKey, robloxUserId)
	-- Defense in depth: callers that bypass setApiKey (legacy migrations,
	-- direct invocations) still get clean input.
	local cleanKey = trimKey(apiKey)
	if cleanKey == "" or #cleanKey < 20 then
		return false, "Invalid API key (too short or empty after trim)"
	end

	local authError = nil
	local authSuccess = false
	-- "credential" | "throttled" | "server" | "transport" — see the 401/403
	-- branch below for why the distinction is load-bearing.
	local authKind = "transport"

	local ok, err = pcall(function()
		local response = HttpService:RequestAsync({
			Url = apiBase .. "/auth",
			Method = "POST",
			Headers = { ["Content-Type"] = "application/json" },
			Body = HttpService:JSONEncode({
				forge_key = cleanKey,
				roblox_user_id = robloxUserId,
				plugin_version = pluginVersion,
			}),
		})

		if response.StatusCode == 200 then
			local data = HttpService:JSONDecode(response.Body)
			sessionToken = data.session_token
			sessionExpiry = os.time() + (data.expires_in or 7200) - 60
			-- Stash the server-advertised latest version + update URL so
			-- init.server.lua can compare against this build and, if newer,
			-- show the update banner (§2). Both optional — older servers omit.
			latestPluginVersion = data.latest_plugin_version
			updateUrl = data.update_url
			-- Persist so plugin reloads don't burn a fresh /auth roundtrip.
			pcall(function()
				plugin_ref:SetSetting(SETTING_TOKEN, sessionToken)
				plugin_ref:SetSetting(SETTING_TOKEN_EXPIRY, sessionExpiry)
			end)
			authSuccess = true
		elseif response.StatusCode == 401 or response.StatusCode == 403 then
			-- SAY WHICH KIND OF WRONG (2026-08-26). Both codes used to render
			-- one frozen sentence, so "you copied the hidden version of your
			-- key", "no account holds this key any more" and "your account is
			-- broken on our side" were all "Invalid API key" — and 66 Roblox
			-- accounts read that message 1,679 times without ever connecting,
			-- one of them 184 times across 15 separate days. The server now
			-- sends a `message` written for the specific failure; fall back to
			-- the old text if we are talking to an older deploy.
			local decoded
			pcall(function() decoded = HttpService:JSONDecode(response.Body) end)
			if type(decoded) == "table" and type(decoded.message) == "string" and decoded.message ~= "" then
				authError = decoded.message
			elseif response.StatusCode == 403 then
				authError = "Key is bound to a different Roblox account"
			else
				authError = "Invalid API key"
			end
			-- The caller needs to know this was the CREDENTIAL and not the
			-- network, because that is what decides whether the paste box comes
			-- back. A dropped connection must never throw the user into a
			-- sign-in form for a key that is perfectly good.
			authKind = "credential"
		elseif response.StatusCode == 429 then
			authError = "Too many attempts. Wait a moment."
			authKind = "throttled"
		else
			authError = "Server error (" .. response.StatusCode .. ")"
			authKind = "server"
		end
	end)

	if not ok then return false, "Connection failed: " .. tostring(err), "transport" end
	if authSuccess then return true end
	return false, authError or "Unknown error", authKind
end

function Auth.isAuthenticated()
	return sessionToken ~= nil and os.time() < sessionExpiry
end

-- Latest published plugin version advertised by the server on the last
-- successful /auth. nil until the first authenticate() succeeds.
function Auth.getLatestVersion()
	return latestPluginVersion
end

-- Where to grab the latest build (display-only; the plugin can't open URLs).
function Auth.getUpdateUrl()
	return updateUrl
end

return Auth
end)()

--[[ === Module: HierarchyReader === ]]
local HierarchyReader = (function()
--[[
	HierarchyReader Module — Serialize game tree for AI context

	Reads relevant services and creates a JSON-serializable hierarchy.
	Constraints:
	- Max depth: 10 levels
	- Script sources: truncated at 2000 chars
	- Total payload: capped at ~500KB
	- Only includes: Workspace, ServerScriptService, ServerStorage,
	  ReplicatedStorage, StarterGui, StarterPlayer, StarterPack, Lighting, SoundService
]]

local HierarchyReader = {}

local HttpService = nil

local MAX_DEPTH = 10
-- Raised from 2000 → 12000 (2026-07-08). At 2000 chars the AI only saw the
-- head of any longer user script, then "rewrote" it and clobbered the hidden
-- tail — a real "your update broke my game" cause reported by a paying user.
-- Most scripts fit under 12000; MAX_TOTAL_SIZE still caps the whole payload.
local MAX_SCRIPT_LENGTH = 12000
local MAX_TOTAL_SIZE = 500000 -- ~500KB

-- Budget held back for Workspace, which is serialised LAST. See SERVICES.
local WORKSPACE_RESERVE = 150000

--[[
	WORKSPACE GOES LAST (2026-08-24) — and why that ordering is a safety fix,
	not a cosmetic one.

	Workspace used to be first. The budget is dominated by script SOURCE
	(currentSize grows by #source below, ~20+name+className for everything
	else), so on a big place Workspace could spend the whole 500KB before the
	walk ever reached ServerScriptService. serialize() then skipped the
	assignment for every service it never read, and A SERVICE KEY THAT IS
	ABSENT IS BYTE-IDENTICAL TO A SERVICE THAT IS EMPTY.

	Measured over the 120 most recent production snapshots:
	    truncated (a service key missing) ......... 16 / 120  (13%)
	    ...losing a service the safety guard counts  16 / 16
	    ...where the guard therefore read "empty" ..  7 / 120  (6%)
	The missing-service distribution matched this list's old order exactly,
	tail first (StarterPack/Lighting/SoundService absent in all 16), which is
	what proves it was the cap and not those users' places. Those 7 are the
	LARGEST games on the platform, and the server would have let a recipe lay
	a second copy of a whole pre-built system over them.

	WHY LOSING WORKSPACE DETAIL IS THE RIGHT TRADE — RE-ARGUED HONESTLY
	(2026-08-24, second pass). The first version of this note claimed Workspace
	loss was RECOVERABLE, via get_hierarchy or query_hierarchy. BOTH CLAIMS WERE
	FALSE and they are worth writing down so nobody rebuilds the argument on
	them: get_hierarchy re-reads a STORED snapshot row and re-slims it, so it
	cannot return a branch this walk never serialised; and query_hierarchy, which
	really does read the live DataModel with no budget, is a handler nothing
	calls — no server code enqueues that command and it is not in the model's
	tool list (CommandExecutor.lua:598-704 is dead code today).

	The trade still holds, on a narrower argument. Neither loss is recoverable
	within a turn, so the question is only WHICH loss is worse, and they are not
	symmetric:
	  • A missing/unread SERVICE is read by the safety guard that decides
	    whether this place is already somebody's game. That guard runs BEFORE
	    the model does anything and cannot ask a follow-up question.
	  • Missing WORKSPACE detail is read by the model, which is told plainly
	    that the branch is unread and instructed not to treat it as empty.
	One of those two silently authorises a recipe to bulldoze a finished game;
	the other produces a worse-informed but supervised turn. So we spend the
	budget on the guard's question. And both losses are now VISIBLE — the
	markers below are the reason this is a degraded answer rather than a wrong
	one, and the server blocks on either of them (detectSnapshotTruncation).

	WHY A RESERVE AND NOT A BARE REORDER. A bare reorder only moves the
	starvation: a game with ~42 max-length scripts under ServerScriptService
	would eat the entire 500KB and leave Workspace with nothing — the user's
	whole world unread, which is how the model ends up rebuilding a map that
	already exists. Everything before Workspace is therefore capped at
	MAX_TOTAL_SIZE - WORKSPACE_RESERVE, so Workspace is guaranteed 150KB of
	budget however script-heavy the rest of the place is. It costs nothing in
	the normal case: a COMPLETE snapshot in production is 27,766 chars at the
	median, so the reserve only ever binds on the places it exists to protect.

	The five services the guard counts come first, then StarterPlayer (whose
	two script folders hold the client half of a game and are counted too),
	then the two cheap ones, then Workspace.
]]
local SERVICES = {
	"ServerScriptService",
	"ServerStorage",
	"ReplicatedStorage",
	"StarterGui",
	"StarterPack",
	"StarterPlayer",
	"Lighting",
	"SoundService",
	"Workspace",
}

function HierarchyReader.init(httpService)
	HttpService = httpService
end

-- `budget` is the ceiling for THIS service (see serialize): everything before
-- Workspace runs against MAX_TOTAL_SIZE - WORKSPACE_RESERVE, Workspace itself
-- against the full MAX_TOTAL_SIZE. currentSize is cumulative across services.
--
-- The `or MAX_TOTAL_SIZE` is deliberate belt-and-braces: if a future edit ever
-- forgets to thread `budget` through the recursive call below, `currentSize[1]
-- > nil` throws, both transports pcall serialize() and return silently, and
-- EVERY user stops uploading snapshots with nothing in Output to say so. A
-- one-line default turns a fleet-wide blackout into the old behaviour.
local function serializeInstance(instance, depth, scripts, currentSize, budget)
	budget = budget or MAX_TOTAL_SIZE
	if depth > MAX_DEPTH then return nil end
	if currentSize[1] > budget then
		-- Record that the budget ran out somewhere, for the top-level flag.
		currentSize.overflowed = true
		return nil
	end

	local node = {
		name = instance.Name,
		className = instance.ClassName,
	}

	-- Capture script source
	if instance:IsA("LuaSourceContainer") then
		local source = ""
		pcall(function()
			source = instance.Source or ""
		end)

		-- Measured on the WHOLE source, before the cut below.
		local trueLines = #source:split("\n")
		local truncated = false

		if #source > MAX_SCRIPT_LENGTH then
			truncated = true
			-- The banner is read by the MODEL, so it may only name moves the
			-- model can make. It used to end "edit surgically or ask the user
			-- to paste the full script" — and neither is legal (2026-08-26):
			-- update_script replaces the whole file, there is no partial-write
			-- tool, and the system prompt bans asking the user to be our eyes
			-- outright. Under an instruction it could not satisfy, the model
			-- bolted wrapper/patch/proxy scripts onto the side of a paying
			-- user's file on 2026-08-25. The first sentence is unchanged on
			-- purpose: the server matches on it (SNAPSHOT_TRUNCATION_MARK in
			-- session-world-model.ts) and older plugins in the field still
			-- send the old tail.
			source = source:sub(1, MAX_SCRIPT_LENGTH)
				.. "\n-- [TRUNCATED — this script is longer than shown."
				.. " Do NOT write this path from what you see here — you would delete the hidden tail."
				.. " Read it again with read_script; if that does not come back, leave the file alone and say so.]"
		end

		local path = instance:GetFullName()
		scripts[path] = source
		node.hasSource = true
		-- COUNT THE FILE, NOT THE PREFIX (2026-08-26). This was
		-- `#source:split("\n")` AFTER the truncation above, so for every script
		-- over 12,000 characters `lines` was the line count of the 12 KB prefix
		-- plus the banner — not of the file. LobbyBuilder is 41,626 chars /
		-- 1,346 lines and the snapshot for the 2026-08-25 incident recorded
		-- lines=365, which is the prefix. Anything server-side that reads this
		-- number to ask "how big is this script really" was being handed the
		-- same truncated figure the model already had, and the whole point of
		-- reading it is to have a number the model cannot argue with. Count
		-- before cutting, and say so.
		node.lines = trueLines
		if truncated then
			node.srcTruncated = true
			node.shownLines = #source:split("\n")
		end
		currentSize[1] = currentSize[1] + #source
	end

	-- Add relevant properties for common types
	if instance:IsA("BasePart") then
		node.size = string.format("%.1f, %.1f, %.1f", instance.Size.X, instance.Size.Y, instance.Size.Z)
		node.position = string.format("%.1f, %.1f, %.1f", instance.Position.X, instance.Position.Y, instance.Position.Z)
		node.orientation = string.format("%.1f, %.1f, %.1f", instance.Orientation.X, instance.Orientation.Y, instance.Orientation.Z)
		node.anchored = instance.Anchored
		node.transparency = instance.Transparency
		node.material = instance.Material.Name
		pcall(function()
			node.color = string.format("%.2f, %.2f, %.2f", instance.Color.R, instance.Color.G, instance.Color.B)
		end)
		if instance:IsA("MeshPart") then
			node.meshId = instance.MeshId
		end
	end

	-- Tool properties (grip orientation for weapons)
	if instance:IsA("Tool") then
		node.gripForward = string.format("%.2f, %.2f, %.2f", instance.GripForward.X, instance.GripForward.Y, instance.GripForward.Z)
		node.gripPos = string.format("%.2f, %.2f, %.2f", instance.GripPos.X, instance.GripPos.Y, instance.GripPos.Z)
		node.gripRight = string.format("%.2f, %.2f, %.2f", instance.GripRight.X, instance.GripRight.Y, instance.GripRight.Z)
		node.gripUp = string.format("%.2f, %.2f, %.2f", instance.GripUp.X, instance.GripUp.Y, instance.GripUp.Z)
		node.canBeDropped = instance.CanBeDropped
		node.requiresHandle = instance.RequiresHandle
	end

	-- Model pivot
	if instance:IsA("Model") then
		local pivot = instance:GetPivot()
		node.pivotPosition = string.format("%.1f, %.1f, %.1f", pivot.Position.X, pivot.Position.Y, pivot.Position.Z)
		local rx, ry, rz = pivot:ToOrientation()
		node.pivotRotation = string.format("%.1f, %.1f, %.1f", math.deg(rx), math.deg(ry), math.deg(rz))
		if instance.PrimaryPart then
			node.primaryPart = instance.PrimaryPart.Name
		end
	end

	-- GUI properties
	if instance:IsA("GuiObject") then
		node.visible = instance.Visible
	end

	-- Serialize children
	local children = instance:GetChildren()
	if #children > 0 and depth < MAX_DEPTH then
		node.children = {}
		for i, child in ipairs(children) do
			if currentSize[1] > budget then
				-- NEVER CUT SILENTLY (2026-08-24). This used to be a bare
				-- `break`, which left a partial `children` array that reads
				-- exactly like a folder that really does hold three things —
				-- and this is the COMMON overflow, hit by all 16 truncated
				-- snapshots, not just the 7 that lost a whole service. Say how
				-- many we never looked at so the model knows to go and read
				-- this branch instead of assuming the list is the whole truth.
				--
				-- This counts the current child plus every one after it, so it
				-- can overcount by the two or three we would have skipped
				-- anyway (Terrain / Camera / "__" names). That direction is the
				-- safe one — it sends the model to look — and the honest
				-- alternative is a scan of the remainder, which is real
				-- per-node work on exactly the biggest places.
				currentSize.overflowed = true
				node.unreadChildren = #children - i + 1
				break
			end

			-- Skip certain instances
			if not child:IsA("Terrain")
				and child.Name ~= "Camera"
				and not child.Name:match("^__") then
				local childNode = serializeInstance(child, depth + 1, scripts, currentSize, budget)
				if childNode then
					table.insert(node.children, childNode)
				end
			end
		end

		if #node.children == 0 then
			node.children = nil
		end
	end

	currentSize[1] = currentSize[1] + #(node.name or "") + #(node.className or "") + 20
	return node
end

function HierarchyReader.serialize()
	local tree = {}
	local scripts = {}
	local currentSize = { 0 } -- Use table for pass-by-reference

	for _, serviceName in ipairs(SERVICES) do
		local ok, service = pcall(function()
			return game:GetService(serviceName)
		end)

		if ok and service then
			-- Workspace is last and gets the full budget; everything before it
			-- is held back so it cannot starve Workspace. See SERVICES.
			local budget = MAX_TOTAL_SIZE
			if serviceName ~= "Workspace" then
				budget = MAX_TOTAL_SIZE - WORKSPACE_RESERVE
			end

			local serviceNode = serializeInstance(service, 1, scripts, currentSize, budget)

			if not serviceNode then
				-- ALWAYS WRITE THE KEY (2026-08-24). This used to be
				-- `if serviceNode then tree[serviceName] = serviceNode end`, so
				-- a service the budget never reached simply vanished from the
				-- snapshot. All nine of these exist on every Roblox place, so
				-- an absent key does not mean "not there" — but nothing
				-- downstream could tell it apart from an empty service, and
				-- that is the failure that let a recipe overwrite the 7 biggest
				-- games in the sample. A node carrying an explicit "I did not
				-- read this" is infinitely better than no node at all: it is a
				-- POSITIVE statement, it cannot be confused with emptiness, and
				-- the server can act on it without inferring anything from
				-- payload size.
				--
				-- unreadChildren here is the service's real direct-child count,
				-- from GetChildren() — O(direct children), the documented
				-- idiom, and nothing cheaper exists (there is no ChildCount
				-- property). NOT GetDescendants(), which allocates every
				-- descendant into a Lua table and would re-introduce, every 8
				-- seconds, exactly the cost this budget exists to avoid.
				serviceNode = {
					name = service.Name,
					className = service.ClassName,
					notRead = true,
				}
				local okCount, count = pcall(function()
					return #service:GetChildren()
				end)
				if okCount and type(count) == "number" and count > 0 then
					serviceNode.unreadChildren = count
				end
			end

			-- A TERRAIN WORLD MUST NOT READ AS AN EMPTY ONE (2026-08-24).
			-- serializeInstance skips Terrain outright (it is a BasePart whose
			-- properties say nothing useful and whose voxels we would never
			-- send), so a place whose entire world is sculpted terrain came
			-- through as a Workspace with almost nothing in it.
			--
			-- CountCells() is Roblox's own documented answer to precisely this
			-- question — "the approximate number of non-empty cells… can be
			-- used to quickly gauge how much terrain geometry exists in the
			-- place", security None, not deprecated. ONE call per snapshot,
			-- never per node.
			--
			-- NOT Terrain.MaxExtents, which is the engine constant
			-- (-32000,-32000,-32000)..(32000,32000,32000) on every place empty
			-- or not — a guard that is always true. NOT GetExtentsSize(), which
			-- does not exist on Terrain (that is a Model method; Terrain
			-- inherits BasePart) and would throw. NOT ReadVoxels, which
			-- materialises 3D arrays into Lua.
			--
			-- The bare presence of a Terrain child is worthless as a signal:
			-- every place has one. Only a non-zero cell count says anything, so
			-- we write the field only when there is terrain to report.
			if serviceName == "Workspace" then
				local okCells, cells = pcall(function()
					return service.Terrain:CountCells()
				end)
				if okCells and type(cells) == "number" and cells > 0 then
					serviceNode.terrainCells = cells
				end
			end

			tree[serviceName] = serviceNode
		end
	end

	return {
		tree = tree,
		scripts = scripts,
		-- Did the budget run out ANYWHERE this pass — including deep inside
		-- Workspace, where every service key is still present and correct.
		-- Additive and optional: nothing today reads it (BridgeClient uploads
		-- only .tree and .scripts, and the /hierarchy route destructures a
		-- fixed field list, so a new top-level payload field would be dropped
		-- rather than stored). The signals that actually cross the wire are the
		-- per-node ones above, which ride inside the hierarchy JSON for free.
		truncated = currentSize.overflowed == true,
	}
end

-- Get a quick summary (instance count, script count) for display
function HierarchyReader.getSummary()
	local instanceCount = 0
	local scriptCount = 0

	local function count(parent, depth)
		if depth > MAX_DEPTH then return end
		for _, child in ipairs(parent:GetChildren()) do
			instanceCount = instanceCount + 1
			if child:IsA("LuaSourceContainer") then
				scriptCount = scriptCount + 1
			end
			count(child, depth + 1)
		end
	end

	for _, serviceName in ipairs(SERVICES) do
		pcall(function()
			local service = game:GetService(serviceName)
			count(service, 1)
		end)
	end

	return {
		instances = instanceCount,
		scripts = scriptCount,
	}
end

return HierarchyReader
end)()

--[[ === Module: CommandExecutor === ]]
local CommandExecutor = (function()
--[[
	CommandExecutor Module — Execute AI commands in Roblox Studio

	Every mutation is wrapped in ChangeHistoryService waypoints for undo support.

	Supported commands:
	- create_script: Create Script/LocalScript/ModuleScript
	- update_script: Modify existing script source
	- read_script: Return script source code
	- create_instance: Create any Instance with properties
	- delete_instance: Remove an instance (force flag bypasses soft-protection)
	- set_property: Set a property on an instance
	- clear_scene: Bulk wipe top-level containers (gated behind explicit connect)
]]

local CommandExecutor = {}

local ChangeHistoryService = nil
local ScriptEditorService = nil
local HttpService = nil

function CommandExecutor.init(changeHistory, scriptEditor, httpService)
	ChangeHistoryService = changeHistory
	ScriptEditorService = scriptEditor
	HttpService = httpService
end

-- Services we refuse to operate on. Even if the user (or a hostile
-- prompt-injection) tries to navigate into them, every command bails.
-- Most of these are containers Studio guards anyway, but defense in
-- depth — let's not rely on Roblox's own ACLs alone.
local PROTECTED_SERVICES = {
	CoreGui = true,           -- Roblox internal UI
	CorePackages = true,
	MaterialService = true,   -- material registry; mutating breaks rendering
	NetworkClient = true,
	NetworkServer = true,
	RunService = true,
	StarterPlayerScripts = false, -- explicitly NOT protected — recipes use it
	StarterCharacterScripts = false,
}

-- Resolve an instance path like "ServerScriptService.Modules.Combat"
local function resolvePath(path)
	if type(path) ~= "string" or path == "" then
		return nil, "empty path"
	end
	-- Reject obvious traversal attempts
	if path:find("..", 1, true) then
		return nil, "invalid path (contains '..')"
	end
	local parts = path:split(".")
	local current = game

	for i, part in ipairs(parts) do
		if part == "" then
			return nil, "invalid path (empty segment)"
		end
		-- Try GetService first for top-level services
		if current == game then
			if PROTECTED_SERVICES[part] then
				return nil, "service '" .. part .. "' is protected from automated mutation"
			end
			local ok, service = pcall(function()
				return game:GetService(part)
			end)
			if ok and service then
				current = service
				continue
			end
		end

		local child = current:FindFirstChild(part)
		if not child then
			return nil, "Instance not found: " .. part .. " in " .. current:GetFullName()
		end
		current = child
	end

	return current
end

-- Pre-flight checks for any script source about to be committed.
-- Known-bad Roblox API regex list — catches code that is valid Lua
-- but uses non-existent Roblox properties (the OnServerFunction
-- class of bug). Each entry is { pattern, fix_message }. Grow this
-- list whenever a new class of bug shows up in the wild.
-- Returns (ok, errMessage). errMessage is fed back to Claude as the
-- tool_result error so it self-corrects in the same turn instead of the
-- user having to press Play and report the runtime crash.
-- HARD-ERROR patterns only: code that DOES NOT WORK at runtime (not just
-- style/deprecation warnings). Each match blocks the script from
-- committing, so false positives are catastrophic — the model retries
-- indefinitely without understanding what's wrong. Rule of thumb before
-- adding a pattern: if a senior Roblox dev would say "that's just
-- deprecated, it still runs", DON'T add it. Only add things that would
-- crash or silently no-op.
local SCRIPT_BAD_PATTERNS = {
	-- Remote/Bindable signal API — wrong property names. Assignment
	-- silently no-ops, runtime invocations queue and drop.
	{ pat = "%.OnServerFunction%s*=", fix = "RemoteFunction has no .OnServerFunction property — assign to .OnServerInvoke instead" },
	{ pat = "%.OnClientFunction%s*=", fix = "RemoteFunction has no .OnClientFunction property — assign to .OnClientInvoke instead" },

	-- Model API hallucinations — these methods don't exist, runtime crash.
	{ pat = ":SetPrimaryPartFromName", fix = "Model has no :SetPrimaryPartFromName method — assign directly: model.PrimaryPart = model:FindFirstChild(\"PartName\")" },
	{ pat = ":SetPrimaryPart%(", fix = "Model has no :SetPrimaryPart method — assign directly: model.PrimaryPart = somePart" },

	-- Player/Character event source confusion — accessing a signal on
	-- the wrong instance. Index-nil at runtime.
	{ pat = "%.Character%.CharacterAdded", fix = "CharacterAdded is a signal on Player, not Character — use player.CharacterAdded:Connect(...)" },
	{ pat = "%.Character%.CharacterRemoving", fix = "CharacterRemoving is on Player, not Character — use player.CharacterRemoving:Connect(...)" },

	-- Humanoid hallucinations — runtime crash.
	{ pat = "[Hh]umanoid%.PrimaryPart", fix = "Humanoid has no .PrimaryPart — characters' root is HumanoidRootPart: character:FindFirstChild(\"HumanoidRootPart\")" },

	-- Enum.KeyCode numeric — Roblox keycodes are spelled out: One, Two,
	-- Three, ..., Zero. Enum.KeyCode.1 / Enum.KeyCode["1"] both crash.
	{ pat = "Enum%.KeyCode%.[0-9]", fix = "Numeric keys aren't Enum.KeyCode.1 — use Enum.KeyCode.One / .Two / ... / .Nine / .Zero" },
	{ pat = "Enum%.KeyCode%[[\"'][0-9][\"']%]", fix = "Numeric keys aren't Enum.KeyCode[\"1\"] — use Enum.KeyCode.One / .Two / ... / .Nine / .Zero" },
	{ pat = "Enum%.KeyCode%[[0-9]+%]", fix = "Numeric keys aren't Enum.KeyCode[1] — use Enum.KeyCode.One / .Two / ... / .Nine / .Zero" },

	-- AlignPosition / AlignOrientation — constraint properties are
	-- MaxForce / Responsiveness / Position / Attachment0 / Attachment1 /
	-- RigidityEnabled / Mode. Not D, not Damping, not Stiffness.
	{ pat = "AlignPosition[^\n]-%.D%s*=", fix = "AlignPosition has no .D / .Damping property — use .Responsiveness (lower = softer) and .MaxForce. To slow it down, lower MaxForce or Responsiveness." },
	{ pat = "AlignPosition[^\n]-%.P%s*=", fix = "AlignPosition has no .P property — that's BodyPosition's .P. AlignPosition uses .Responsiveness and .MaxForce." },
	{ pat = "AlignOrientation[^\n]-%.D%s*=", fix = "AlignOrientation has no .D / .Damping property — use .Responsiveness and .MaxTorque." },
	{ pat = "AlignOrientation[^\n]-%.P%s*=", fix = "AlignOrientation has no .P property — that's BodyGyro's .P. AlignOrientation uses .Responsiveness and .MaxTorque." },

	-- Terrain — protected service. :Destroy() throws "Cannot Destroy() Terrain"
	-- at runtime. To wipe terrain voxels use :Clear(). To clear specific cells
	-- use :ReplaceMaterial or write empty voxels via :WriteVoxels.
	{ pat = "[Tt]errain%s*:%s*Destroy", fix = "Terrain cannot be destroyed (Roblox protects it). Use Terrain:Clear() to wipe all voxels, or :ReplaceMaterial / :WriteVoxels for partial clears." },
	{ pat = "workspace%.Terrain%s*:%s*Destroy", fix = "Terrain cannot be destroyed (Roblox protects it). Use workspace.Terrain:Clear() instead." },

	-- Lighting.Technology — RobloxScript-only property. Regular Scripts
	-- get "lacking capability RobloxScript" at runtime, halting the
	-- entire script. Cinematic scenes that try to set Future/ShadowMap/
	-- Voxel rendering all hit this. The user must change Technology in
	-- the Studio Properties panel; scripts can't write it.
	{ pat = "[Ll]ighting%.Technology%s*=", fix = "DELETE THIS LINE. Lighting.Technology is read-only from Scripts — there is NO permission to request, this is not a capability problem you can work around. Just remove the assignment, leave Technology at its default, and continue with the rest of the setup. The user can change Technology manually in Studio Properties (Lighting → Technology dropdown) if they want a different rendering mode; mention that as a one-line note in your final reply, not by retrying with another method." },
	{ pat = "[Ll]ighting%[\"Technology\"%]%s*=", fix = "DELETE THIS LINE. Lighting.Technology is read-only from Scripts — no permission to request. Remove the assignment and continue." },

	-- Sky property hallucinations — wrong short names. Silent no-op or
	-- runtime "is not a valid member" depending on Studio version.
	{ pat = "%.SunSize%s*=", fix = "Sky has no .SunSize — the property is .SunAngularSize (size in degrees, default 11)" },
	{ pat = "%.MoonSize%s*=", fix = "Sky has no .MoonSize — the property is .MoonAngularSize" },

	-- Constraint hallucinations — these classes don't exist. Instance.new
	-- crashes immediately with "Unable to create an Instance of type X".
	{ pat = "Instance%.new%(%s*[\"']AlignVelocity[\"']%s*%)", fix = "AlignVelocity does not exist — use Instance.new(\"LinearVelocity\") with .VectorVelocity / .MaxForce / .Attachment0" },
	{ pat = "Instance%.new%(%s*[\"']AlignAngularVelocity[\"']%s*%)", fix = "AlignAngularVelocity does not exist — use Instance.new(\"AngularVelocity\") with .AngularVelocity (Vector3) / .MaxTorque / .Attachment0" },

	-- BindableEvent vs RBXScriptSignal confusion — :Fire() only exists
	-- on BindableEvent (and RemoteEvent). Native engine signals like
	-- :Touched, :Changed, :GetPropertyChangedSignal() return RBXScriptSignal,
	-- which has :Connect / :Wait / :Once but no :Fire — that would be
	-- the engine firing them, not your code. Pattern: "<Touched|Changed|
	-- ChildAdded|...>:Fire(" → flag.
	{ pat = ":Touched%s*:%s*Fire%(", fix = "RBXScriptSignal :Touched cannot be :Fire'd from your code — only the engine fires it. Use a BindableEvent if you need to fire your own signal." },
	{ pat = ":Changed%s*:%s*Fire%(", fix = "RBXScriptSignal :Changed cannot be :Fire'd from your code — only the engine fires it. Assign the property to trigger it, or use a BindableEvent for custom signals." },
	{ pat = ":ChildAdded%s*:%s*Fire%(", fix = "RBXScriptSignal :ChildAdded cannot be :Fire'd — fired by engine when child parented. Use BindableEvent for custom." },
	{ pat = ":AncestryChanged%s*:%s*Fire%(", fix = "RBXScriptSignal :AncestryChanged cannot be :Fire'd from code — engine-only. Use BindableEvent for custom signals." },

	-- Visible-scene anti-patterns — generating 1000 tiny Parts to fake stars
	-- looks empty in StreamingEnabled because most never replicate to client.
	-- Real starfields use a Skybox texture (Lighting service) or a single
	-- skydome MeshPart, never thousands of small Parts.
	-- (No regex check here — too easy to false-positive — handled in prompt.)
}

local function validateScriptSource(source)
	if type(source) ~= "string" or source == "" then
		return false, "script source is empty"
	end
	-- Known-bad API patterns.
	for _, rule in ipairs(SCRIPT_BAD_PATTERNS) do
		if source:find(rule.pat) then
			return false, "Roblox API misuse: " .. rule.fix
		end
	end
	return true
end

-- Create a script (Script, LocalScript, or ModuleScript)
local function createScript(payload)
	local parent, err = resolvePath(payload.parent)
	if not parent then
		return false, nil, err
	end

	local validOk, validErr = validateScriptSource(payload.source)
	if not validOk then
		return false, nil, validErr
	end

	-- Check if script already exists
	local existing = parent:FindFirstChild(payload.name)
	if existing and existing:IsA("LuaSourceContainer") then
		-- Update existing script instead
		existing.Source = payload.source
		return true, { path = existing:GetFullName(), action = "updated_existing" }
	end

	local scriptType = payload.script_type or payload.scriptType or "Script"
	local newScript = Instance.new(scriptType)
	newScript.Name = payload.name
	newScript.Source = payload.source
	newScript.Parent = parent

	return true, { path = newScript:GetFullName() }
end

-- Update an existing script's source
local function updateScript(payload)
	local target, err = resolvePath(payload.path)
	if not target then
		return false, nil, err
	end

	if not target:IsA("LuaSourceContainer") then
		return false, nil, payload.path .. " is not a script"
	end

	local validOk, validErr = validateScriptSource(payload.source)
	if not validOk then
		return false, nil, validErr
	end

	target.Source = payload.source
	return true, { path = target:GetFullName(), lines = #payload.source:split("\n") }
end

-- Read a script's source code
local function readScript(payload)
	local target, err = resolvePath(payload.path)
	if not target then
		return false, nil, err
	end

	if not target:IsA("LuaSourceContainer") then
		return false, nil, payload.path .. " is not a script"
	end

	-- Try ScriptEditorService first (gets edit-time source)
	local source = nil
	pcall(function()
		source = ScriptEditorService:GetEditorSource(target)
	end)

	if not source then
		source = target.Source
	end

	return true, { path = target:GetFullName(), source = source }
end

-- Create any instance with properties
local function createInstance(payload)
	-- Race-condition retry. Telemetry 2026-05-20 (mickelharriott939):
	-- BasketballCourt Folder created at 18:28:40, then 26 of 60 child
	-- create_instance calls failed with "Instance not found" within
	-- the same second. Roblox/plugin race — FindFirstChild on the
	-- new parent sometimes can't see it yet. Mitigation: when
	-- resolvePath fails on FIRST attempt, wait 150ms and retry once.
	-- Earlier the server-side dispatch did this via a new bridge_command
	-- row, which still works as a fallback but adds ~3s of polling
	-- latency. Doing it inline here is invisible to the user.
	local parent, err = resolvePath(payload.parent)
	if not parent and err and err:find("Instance not found") then
		task.wait(0.15)
		parent, err = resolvePath(payload.parent)
	end
	if not parent then
		return false, nil, err
	end

	local ok, instance = pcall(function()
		return Instance.new(payload.class_name or payload.className)
	end)

	if not ok then
		return false, nil, "Invalid class: " .. (payload.class_name or payload.className or "nil")
	end

	instance.Name = payload.name or payload.class_name or payload.className

	-- Set properties — coerce strings to the right type by inspecting
	-- the property's CURRENT value type on the instance. This way Size
	-- on a Frame becomes UDim2, Size on a Part becomes Vector3, etc.
	-- TYPE GUARD: properties must be a table. Telemetry 2026-05-18:
	-- 6x "invalid argument #1 to 'pairs'" crashes when the model
	-- sent properties as a string or scalar. Skip with a warn rather
	-- than crash the whole command.
	if payload.properties and type(payload.properties) == "table" then
		for prop, value in pairs(payload.properties) do
			pcall(function()
				local nums = {}
				if type(value) == "string" then
					for n in value:gmatch("[%-%.%d]+") do
						table.insert(nums, tonumber(n))
					end
				end

				-- Probe the existing property value to learn its type.
				local existingOk, existing = pcall(function() return instance[prop] end)
				local existingType = existingOk and typeof(existing) or nil

				if existingType == "UDim2" and #nums >= 4 then
					-- "sx,ox,sy,oy" → UDim2.new(sx, ox, sy, oy)
					instance[prop] = UDim2.new(nums[1], nums[2], nums[3], nums[4])
				elseif existingType == "UDim" and #nums >= 2 then
					-- "scale,offset" → UDim.new(scale, offset). UICorner.CornerRadius etc.
					instance[prop] = UDim.new(nums[1], nums[2])
				elseif existingType == "Vector2" and #nums >= 2 then
					instance[prop] = Vector2.new(nums[1], nums[2])
				elseif existingType == "Vector3" and #nums >= 3 then
					instance[prop] = Vector3.new(nums[1], nums[2], nums[3])
				elseif existingType == "Color3" and #nums >= 3 then
					-- Heuristic: if any value > 1, assume 0-255 RGB and normalize.
					local r, g, b = nums[1], nums[2], nums[3]
					if r > 1 or g > 1 or b > 1 then
						instance[prop] = Color3.fromRGB(r, g, b)
					else
						instance[prop] = Color3.new(r, g, b)
					end
				elseif existingType == "CFrame" and #nums >= 3 then
					if #nums >= 12 then
						instance[prop] = CFrame.new(
							nums[1], nums[2], nums[3],
							nums[4], nums[5], nums[6],
							nums[7], nums[8], nums[9],
							nums[10], nums[11], nums[12]
						)
					else
						instance[prop] = CFrame.new(nums[1], nums[2], nums[3])
					end
				elseif existingType == "EnumItem" and type(value) == "string" then
					-- e.g. Material="Neon", Font="GothamBold". Resolve via the
					-- enum the property currently belongs to.
					local enumName = tostring(existing.EnumType)
					-- enumName is like "Enum.Material" — strip "Enum."
					local pureEnum = enumName:gsub("^Enum%.", "")
					local enumTable = Enum[pureEnum]
					if enumTable then
						instance[prop] = enumTable[value] or existing
					end
				elseif existingType == "BrickColor" and type(value) == "string" then
					instance[prop] = BrickColor.new(value)
				else
					-- Fall through: assume value is already correct type
					-- (number, boolean, string, Instance reference). Roblox
					-- will throw if mismatch — pcall above swallows it.
					instance[prop] = value
				end
			end)
		end
	end

	instance.Parent = parent
	return true, { path = instance:GetFullName(), className = instance.ClassName }
end

-- Names that ship with every Roblox place and shouldn't be deleted via plugin.
-- Removing these breaks default behavior and is almost never the user's intent.
local PROTECTED_NAMES = {
	["Camera"] = true,
	["Terrain"] = true,
	["Baseplate"] = true,
	["SpawnLocation"] = true,
	["StarterCharacter"] = true,
	["Animate"] = true,           -- default character animator
	["Animation"] = true,
	["Health"] = true,             -- default health regen
}

-- Delete an instance
local function deleteInstance(payload)
	local target, err = resolvePath(payload.path)
	if not target then
		return false, nil, err
	end

	-- Don't delete services (top-level DataModel children) — ever
	if target.Parent == game then
		return false, nil, "Cannot delete a service"
	end

	-- Soft protection: default Roblox-shipped instances (Baseplate,
	-- SpawnLocation, Animate, Health, etc) are accident-magnets so we
	-- block by default. Pass `force: true` to override — this is what
	-- explicit-wipe intent uses, since the user wants a clean slate.
	if not payload.force and PROTECTED_NAMES[target.Name] and target.Parent and (target.Parent == workspace or target.Parent.Parent == game) then
		return false, nil, "Protected instance: " .. target.Name .. " — pass force:true to override"
	end

	local fullName = target:GetFullName()
	target:Destroy()
	return true, { deleted = fullName }
end

-- Set a property on an existing instance
-- Same string-coercion logic createInstance uses, factored out so set_property
-- and (future) bulk-property tools can share it.
local function coerceValue(instance, prop, value)
	if type(value) ~= "string" then
		return value
	end
	local nums = {}
	for n in value:gmatch("[%-%.%d]+") do
		table.insert(nums, tonumber(n))
	end
	local existingOk, existing = pcall(function() return instance[prop] end)
	local existingType = existingOk and typeof(existing) or nil

	if existingType == "UDim2" and #nums >= 4 then
		return UDim2.new(nums[1], nums[2], nums[3], nums[4])
	elseif existingType == "UDim" and #nums >= 2 then
		return UDim.new(nums[1], nums[2])
	elseif existingType == "Vector2" and #nums >= 2 then
		return Vector2.new(nums[1], nums[2])
	elseif existingType == "Vector3" and #nums >= 3 then
		return Vector3.new(nums[1], nums[2], nums[3])
	elseif existingType == "Color3" and #nums >= 3 then
		local r, g, b = nums[1], nums[2], nums[3]
		if r > 1 or g > 1 or b > 1 then
			return Color3.fromRGB(r, g, b)
		end
		return Color3.new(r, g, b)
	elseif existingType == "CFrame" then
		if #nums >= 12 then
			return CFrame.new(nums[1], nums[2], nums[3], nums[4], nums[5], nums[6], nums[7], nums[8], nums[9], nums[10], nums[11], nums[12])
		elseif #nums >= 3 then
			return CFrame.new(nums[1], nums[2], nums[3])
		end
	elseif existingType == "EnumItem" then
		local enumName = tostring(existing.EnumType):gsub("^Enum%.", "")
		local enumTable = Enum[enumName]
		if enumTable and enumTable[value] then
			return enumTable[value]
		end
	elseif existingType == "BrickColor" then
		return BrickColor.new(value)
	end
	return value
end

-- Properties set_property must never write, whatever the server sends.
-- `Source` is a whole-script overwrite in a property's clothes: it bypasses
-- the read-before-overwrite guard and the Luau lint gate, both of which only
-- inspect create_script / update_script (2026-08-26). Script bodies go
-- through update_script, which is checked. Defence in depth — the server
-- refuses this too, in forge-ai.ts's set_property pre-flight.
local BLOCKED_PROPERTIES = {
	Source = true,
}

local function setProperty(payload)
	local target, err = resolvePath(payload.path)
	if not target then
		return false, nil, err
	end

	if BLOCKED_PROPERTIES[payload.property] then
		return false, nil, "set_property cannot write " .. tostring(payload.property)
			.. " — use update_script for script source so the overwrite guard and lint gate apply."
	end

	local coerced = coerceValue(target, payload.property, payload.value)
	local propOk, propErr = pcall(function()
		target[payload.property] = coerced
	end)

	if not propOk then
		return false, nil, "Failed to set " .. payload.property .. " on " .. payload.path .. ": " .. tostring(propErr)
	end

	return true, { path = target:GetFullName(), property = payload.property }
end

-- run_luau intentionally REMOVED in forge-bridge. The bridge plugin only
-- accepts structured ops (Rojo-style). Probes that used run_luau in the
-- legacy plugin are replaced by query_hierarchy / query_property handlers
-- (added next iteration).

-- Undo last change
local function undo()
	local ok, err = pcall(function()
		ChangeHistoryService:Undo()
	end)
	if not ok then
		return false, nil, "Undo failed: " .. tostring(err)
	end
	return true, { action = "undo" }
end

-- Redo last undone change
local function redo()
	local ok, err = pcall(function()
		ChangeHistoryService:Redo()
	end)
	if not ok then
		return false, nil, "Redo failed: " .. tostring(err)
	end
	return true, { action = "redo" }
end

-- Bulk wipe: remove every child of the given top-level containers.
-- One round-trip vs. dozens of delete_instance calls. Used for explicit
-- "delete everything / wipe / reset" intent. Skips Roblox services
-- (Camera, Terrain) and the forge-bridge plugin's own scripts so the
-- bridge connection survives.
local function clearScene(payload)
	local DEFAULT_ROOTS = {
		"Workspace",
		"ServerScriptService",
		"ServerStorage",         -- v1.0.8: was MISSING — pet/zombie/building
		                          -- templates lived here forever after a
		                          -- "remove everything" from earlier turns.
		"ReplicatedStorage",
		"ReplicatedFirst",        -- v1.0.8: also added — early-load assets
		                          -- (loading screens, splash UI) leaked
		                          -- across builds otherwise.
		"StarterGui",
		"StarterPack",
		"StarterPlayer",
		-- Lighting children (Atmosphere, Bloom, ColorCorrection, Sky,
		-- BlurEffect, DepthOfField, SunRays). Without this clear_scene
		-- leaks lighting state across scenes — user asks for "cartoon"
		-- but yesterday's "cyberpunk" Atmosphere/Bloom is still there
		-- and the new script just modifies it instead of replacing.
		"Lighting",
	}
	-- Containers Roblox locks against Destroy() but whose own children we
	-- still need to wipe. StarterPlayer.StarterPlayerScripts is the worst
	-- offender — old LocalScripts survive a clear_scene because the
	-- container above them can't be destroyed.
	local DEEP_CONTAINERS = {
		StarterPlayer = { "StarterPlayerScripts", "StarterCharacterScripts" },
	}
	local roots = payload.roots or DEFAULT_ROOTS
	local removed = 0
	local skipped = 0
	local SIGNATURE = "forge-bridge"

	local function shouldSkipScript(child)
		local srcOk, src = pcall(function() return child.Source end)
		return srcOk and type(src) == "string" and src:find(SIGNATURE, 1, true)
	end

	local function wipeChildren(container)
		local kids = container:GetChildren()
		for _, child in ipairs(kids) do
			local cls = child.ClassName
			if cls == "Camera" or cls == "Terrain" then
				skipped = skipped + 1
			elseif child:IsA("Script") or child:IsA("LocalScript") or child:IsA("ModuleScript") then
				if shouldSkipScript(child) then
					skipped = skipped + 1
				else
					local delOk = pcall(function() child:Destroy() end)
					if delOk then removed = removed + 1 else skipped = skipped + 1 end
				end
			else
				local delOk = pcall(function() child:Destroy() end)
				if delOk then removed = removed + 1 else skipped = skipped + 1 end
			end
		end
	end

	for _, rootName in ipairs(roots) do
		local ok, root = pcall(function() return game:GetService(rootName) end)
		if not ok or not root then
			root = game:FindFirstChild(rootName)
		end
		if root then
			wipeChildren(root)
			local subs = DEEP_CONTAINERS[rootName]
			if subs then
				for _, subName in ipairs(subs) do
					local sub = root:FindFirstChild(subName)
					if sub then wipeChildren(sub) end
				end
			end
		end
	end

	-- Reset Lighting service-level properties to engine defaults so the
	-- next scene starts from a clean slate (Brightness, ClockTime,
	-- Ambient, Fog settings persist on the service even after we wipe
	-- its child effects). Without this reset, "build me a cartoon X"
	-- after a "build me a cyberpunk Y" inherits cyberpunk's
	-- Brightness=0.8 and the cartoon scene comes out dim.
	pcall(function()
		local Lighting = game:GetService("Lighting")
		Lighting.Ambient = Color3.fromRGB(0, 0, 0)
		Lighting.OutdoorAmbient = Color3.fromRGB(127, 127, 127)
		Lighting.Brightness = 2
		Lighting.ClockTime = 14
		Lighting.GeographicLatitude = 41.733
		Lighting.ExposureCompensation = 0
		Lighting.EnvironmentDiffuseScale = 1
		Lighting.EnvironmentSpecularScale = 1
		Lighting.GlobalShadows = true
		Lighting.FogStart = 0
		Lighting.FogEnd = 100000
		Lighting.FogColor = Color3.fromRGB(192, 192, 192)
	end)

	return true, { removed = removed, skipped = skipped, roots = roots }
end

-- query_hierarchy — on-demand subtree read. Server-side get_hierarchy
-- already reads from game_hierarchy_snapshots (the periodic push from
-- BridgeClient.uploadHierarchy), which is typically <10s stale. But
-- when the AI needs FRESH state right after a mutation batch (without
-- waiting for the 8s upload throttle), it can request this directly.
-- Lightweight: returns names + classes for the immediate subtree, no
-- properties.
local function queryHierarchy(payload)
	local parentPath = payload.parent_path or payload.path or "game"
	local root, err
	if parentPath == "game" or parentPath == "" then
		root, err = game, nil
	else
		root, err = resolvePath(parentPath)
	end
	if not root then
		return false, nil, err or "invalid path"
	end
	local children = {}
	pcall(function()
		for _, c in ipairs(root:GetChildren()) do
			table.insert(children, {
				name = c.Name,
				class_name = c.ClassName,
				path = c:GetFullName(),
			})
			if #children >= 500 then break end
		end
	end)
	return true, { parent = parentPath, count = #children, children = children }
end

-- build_tree — materialize a whole nested instance tree in ONE command.
--
-- WHY (2026-07-20): recipes used to ship the entire world inside one server
-- Script that runs on Play, because emitting parts as individual
-- create_instance ops meant one bridge round-trip PER PART (13-23s each — see
-- the bridge-latency note). That made the world invisible/uneditable in Edit
-- mode: Explorer showed only the ground until you pressed Play (Jack's report,
-- Jul 20). This builds an arbitrarily deep tree locally in a single round-trip,
-- so the STATIC world appears in Explorer immediately — editable, movable,
-- saveable — while a small behavior Script (create_script) wires the runtime
-- logic on Play. That's the "hybrid" edit-mode build.
--
-- Spec node: { class_name, name?, properties?={..}, children?=[node,..] }
-- Properties use the same string-coercion as create_instance (coerceValue).
local BUILD_TREE_MAX_NODES = 6000  -- safety cap; a dense city is ~1-2k parts
local function buildTree(payload)
	local parent, err = resolvePath(payload.parent or payload.parent_path or "game.Workspace")
	if not parent and err and err:find("Instance not found") then
		task.wait(0.15)
		parent, err = resolvePath(payload.parent or payload.parent_path or "game.Workspace")
	end
	if not parent then
		return false, nil, err or "invalid parent"
	end
	local root = payload.tree or payload.root
	if type(root) ~= "table" then
		return false, nil, "build_tree: missing tree spec"
	end

	local count = 0
	local function build(spec, whereParent)
		if count >= BUILD_TREE_MAX_NODES then
			error("build_tree exceeded " .. BUILD_TREE_MAX_NODES .. " nodes")
		end
		local className = spec.class_name or spec.className
		if not className then return end
		local okNew, inst = pcall(function() return Instance.new(className) end)
		if not okNew then return end  -- skip unknown class, keep going
		count += 1
		inst.Name = spec.name or className
		if type(spec.properties) == "table" then
			for prop, value in pairs(spec.properties) do
				pcall(function()
					inst[prop] = coerceValue(inst, prop, value)
				end)
			end
		end
		-- Parent AFTER properties so physics/anchoring settle once, not per-prop.
		inst.Parent = whereParent
		if type(spec.children) == "table" then
			for _, child in ipairs(spec.children) do
				build(child, inst)
			end
		end
	end

	local ok, buildErr = pcall(function() build(root, parent) end)
	if not ok then
		-- Partial build is still useful (and undoable) — report what landed.
		return false, { parent = parent:GetFullName(), built = count }, tostring(buildErr)
	end
	return true, { parent = parent:GetFullName(), built = count }
end

-- Command dispatch table
local COMMANDS = {
	create_script = createScript,
	update_script = updateScript,
	read_script = readScript,
	create_instance = createInstance,
	build_tree = buildTree,
	delete_instance = deleteInstance,
	set_property = setProperty,
	clear_scene = clearScene,
	query_hierarchy = queryHierarchy,
	undo = undo,
	redo = redo,
}

-- Probe Studio's script-injection permission. Creates a throwaway Script
-- in ServerStorage, immediately destroys it. If Studio blocks plugin
-- script injection ("Plugin X was denied script injection permission"),
-- pcall returns false with that error and we report `denied`. If anything
-- else goes wrong we treat it as `unknown` and let real commands surface
-- the real error — never block the UI on a probe failure.
--
-- Why this exists: 2026-05-16 telemetry — 5 active users that day, 4 of
-- them had every create_script call fail with "denied script injection
-- permission". They had clicked "Don't Allow" on Studio's popup. The
-- bridge had no way to tell the UI, so users saw silent fails and one
-- wrote "игра не работает" (the game doesn't work) before quitting.
-- Probing once on connect lets the UI surface a visible red banner
-- before the user wastes credits on doomed builds.
function CommandExecutor.probeScriptInjection()
	local ok, err = pcall(function()
		local s = Instance.new("Script")
		s.Source = "-- picoo probe (auto-removed)"
		s.Name = "__PicooInjectionProbe"
		s.Parent = game:GetService("ServerStorage")
		s:Destroy()
	end)
	if ok then return "allowed" end
	local msg = tostring(err or "")
	if msg:find("denied script injection") or msg:find("script injection.*denied")
		or msg:find("betik ekleme izni") or msg:find("permissão para injeção")
		or msg:find("não recebeu permissão") then
		return "denied", msg
	end
	return "unknown", msg
end

-- Destructive-op gate. clear_scene (and force-delete) bulk-Destroy the
-- user's content, so we only run them when the user has EXPLICITLY
-- connected this session (Connect click sets confirmDestructive=true) and
-- we always print a visible, undoable log line before executing. Undo is
-- still available via ChangeHistoryService, but the log + explicit-connect
-- gate means a polled command can't silently wipe a place the user never
-- opted into.
local confirmDestructive = false
function CommandExecutor.setDestructiveConfirmed(v)
	confirmDestructive = v and true or false
end

local function isDestructive(command)
	if command.type == "clear_scene" then return true end
	if command.type == "delete_instance" then
		local p = command.payload or {}
		return p.force == true
	end
	return false
end

function CommandExecutor.execute(command)
	local handler = COMMANDS[command.type]
	if not handler then
		return false, nil, "Unknown command type: " .. tostring(command.type)
	end

	-- Gate destructive bulk ops behind an explicit connect + a visible log.
	if isDestructive(command) then
		if not confirmDestructive then
			return false, nil, "Destructive op '" .. tostring(command.type) .. "' blocked — click Connect in the Picoo widget to authorize scene-clearing/force-delete for this session."
		end
		warn("[picoo] DESTRUCTIVE op '" .. tostring(command.type) .. "' — bulk-modifying scene content (undo with Ctrl+Z).")
	end

	-- Undo/Redo don't need recording wrapper
	if command.type == "undo" or command.type == "redo" then
		return handler(command.payload)
	end

	-- Set undo waypoint before execution
	local recording = ChangeHistoryService:TryBeginRecording("Picoo: " .. command.type)

	local ok, result, errMsg = handler(command.payload)

	-- Finalize undo recording
	if recording then
		if ok then
			ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
		else
			ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
		end
	end

	return ok, result, errMsg
end

return CommandExecutor
end)()

--[[ === Module: BridgeClient === ]]
local BridgeClient = (function()
--[[
	BridgeClient — polls /api/forge/bridge for pending commands, dispatches
	to CommandExecutor, reports results back. Runs on a task.spawn loop.

	Protocol (matches legacy plugin):
	  GET  /api/forge/bridge?session_id=X   { x-forge-token: TOKEN }
	    → 200 { commands: [...] } or [] / 401 expired
	  POST /api/forge/bridge                 { x-forge-token: TOKEN }
	    Body: { results: [{ id, success, result, error }] }
]]

local BridgeClient = {}

local HttpService = nil
local apiBase = ""
local sessionId = ""
local Auth = nil
local CommandExecutor = nil

-- Idle backoff: starts at 2s, ramps to 45s after 30 consecutive empty
-- polls (~1 minute of inactivity). Cuts polling load ~20× when the
-- developer is reading code instead of prompting — a slow idle beacon
-- reads far less like a bot than a fixed fast poll. The /chat endpoint
-- inserts a bridge_command immediately so any new prompt drops the
-- backoff back to 2s on the next poll.
local POLL_INTERVAL_MIN = 2.0      -- seconds between polls when active
-- MUST STAY WELL UNDER THE SERVER'S COMMAND TIMEOUT (2026-08-26).
--
-- This was 45.0 while the server cancels an unclaimed command after 35s
-- (BRIDGE_WAIT_MS = 35_000, src/lib/forge-ai.ts:212). A plugin that had ramped
-- to full backoff therefore MISSED COMMANDS BY CONSTRUCTION: the command was
-- marked "superseded -- timed out before the plugin claimed it" ten seconds
-- before the plugin next asked for work.
--
-- It hits the ordinary case, not an edge case. The ramp starts after 30 empty
-- polls and reaches the ceiling ~30 polls later, i.e. a couple of minutes of
-- sitting still -- which is exactly what a user does between connecting and
-- deciding what to type. Reproduced live 2026-08-26: connect, pause, send
-- "make an obby with 15 stages and checkpoints", and all 8 commands failed
-- without the plugin ever seeing them.
--
-- The sleep does not need to be long: pollOnce is a LONG-poll the server holds
-- open for 25s (bridge/route.ts LONG_POLL_MS) and releases within 500ms of a
-- command appearing, so the sleep is pure dead time on top of that. Worst-case
-- time-to-claim is now ~8.5s against a 35s budget -- a 4x margin -- while still
-- cutting idle requests roughly 4x versus POLL_INTERVAL_MIN.
local POLL_INTERVAL_MAX = 8.0      -- max idle backoff; see above, keep << 35s
local IDLE_RAMP_AFTER = 30         -- empty polls before ramping
local POLL_INTERVAL_BUSY = 0.5     -- back-off shortened when actively running
local idlePolls = 0
local function currentIdleInterval()
	if idlePolls < IDLE_RAMP_AFTER then return POLL_INTERVAL_MIN end
	-- Linear ramp from MIN → MAX over the next 30 idle polls
	local t = math.min(1, (idlePolls - IDLE_RAMP_AFTER) / 30)
	return POLL_INTERVAL_MIN + (POLL_INTERVAL_MAX - POLL_INTERVAL_MIN) * t
end
local running = false              -- guards re-entrant start
local stopped = true               -- set by stop()

local listeners = {
	onCommand = nil,    -- (commandType, payload) → optional, for telemetry/UI
	onError = nil,      -- (errString) → optional
}

function BridgeClient.init(httpService, base, sid, authMod, executorMod)
	HttpService = httpService
	apiBase = base
	sessionId = sid
	Auth = authMod
	CommandExecutor = executorMod
end

function BridgeClient.setSessionId(sid)
	sessionId = sid
end

function BridgeClient.on(event, callback)
	listeners["on" .. event:sub(1,1):upper() .. event:sub(2)] = callback
end

local function emit(name, ...)
	local cb = listeners[name]
	if cb then pcall(cb, ...) end
end

local function pollOnce(token)
	local ok, result = pcall(function()
		-- No session_id query — server falls back to auth.sessionId (the
		-- session_token from x-forge-token), which is what /api/forge/chat
		-- writes commands against. One source of truth.
		return HttpService:RequestAsync({
			Url = apiBase .. "/bridge",
			Method = "GET",
			Headers = { ["x-forge-token"] = token },
		})
	end)
	if not ok then
		return nil, "Request failed: " .. tostring(result)
	end
	if result.StatusCode == 401 then return "expired" end
	if result.StatusCode ~= 200 then
		return nil, "HTTP " .. result.StatusCode
	end
	-- Decode INSIDE a pcall. A 200 with a non-JSON body (Cloudflare/CDN
	-- interstitial, truncated body, a bare "null") used to throw here,
	-- outside any protection, killing the poll coroutine while the UI still
	-- showed "Connected" — the silent-dead-bridge failure. Now it's an error.
	local decodeOk, parsed = pcall(function()
		return HttpService:JSONDecode(result.Body)
	end)
	if not decodeOk or type(parsed) ~= "table" then
		return nil, "Non-JSON response body"
	end
	return parsed.commands or {}
end

local function reportResults(token, results)
	if #results == 0 then return end
	pcall(function()
		HttpService:RequestAsync({
			Url = apiBase .. "/bridge",
			Method = "POST",
			Headers = {
				["Content-Type"] = "application/json",
				["x-forge-token"] = token,
			},
			Body = HttpService:JSONEncode({ results = results }),
		})
	end)
end

-- Push the current Workspace + ServerScriptService + StarterGui + StarterPack
-- + ReplicatedStorage tree to the server so the AI's hierarchy snapshot is
-- fresh on the next chat turn. Without this, AI sees stale state from
-- whenever the legacy plugin last uploaded — and tries to delete instances
-- that no longer exist (or misses ones it should).
--
-- Throttled: we only push if at least UPLOAD_INTERVAL seconds have elapsed
-- since last push, OR if a mutation just changed state (`force = true`).
local UPLOAD_INTERVAL = 8.0
-- Re-send an UNCHANGED snapshot at most this often. An idle Studio session
-- otherwise re-uploads the identical full tree + all script sources every
-- 8s — megabytes/minute of duplicate JSON that Vercel bills per byte as
-- Fast Origin Transfer (555GB/$36 in Jul 2026).
local UNCHANGED_RESEND_INTERVAL = 300.0
local lastUploadAt = 0
local lastUploadAttemptAt = 0
local lastUploadBody = nil
local lastScriptsHash = nil   -- see uploadHierarchy: skips re-sending unchanged sources
-- uploadHierarchy used to set lastUploadAt BEFORE the HTTP request,
-- so a failed upload still blocked the next 8 seconds of throttling.
-- Telemetry 2026-05-19: jessrelnoro hit an 8-minute snapshot gap
-- despite bridge mutations succeeding — force=true uploads were
-- failing silently inside pcall and the throttle was preventing
-- recovery. Now lastUploadAt only advances on success, plus the
-- ATTEMPT timestamp lets us throttle the retry separately so a
-- failing HTTP doesn't busy-loop.
local function uploadHierarchy(token, force)
	local now = os.clock()
	if not force and (now - lastUploadAt) < UPLOAD_INTERVAL then
		return
	end
	-- Don't retry a failed request faster than every 2s
	if (now - lastUploadAttemptAt) < 2 then
		return
	end
	lastUploadAttemptAt = now

	local ok, snapshot = pcall(HierarchyReader.serialize)
	if not ok or not snapshot then
		-- SAY IT OUT LOUD (2026-08-24). This branch used to return in silence,
		-- and silence here is worse than a crash. If serialize() throws, the
		-- plugin still loads, the dock still renders, Connect still goes green
		-- and commands still execute -- nothing looks wrong -- but no snapshot
		-- ever reaches the server. And the server treats "no snapshot" exactly
		-- like a session that never uploaded, which ALLOWS recipes. So a fault
		-- in the reader does not merely blind the AI: it silently inverts the
		-- guard that stops a recipe bulldozing somebody's finished game.
		--
		-- One warn() is the whole difference between a user telling us "it
		-- built the wrong thing again" and us reading the reason in Output.
		-- Throttled by the 2s lastUploadAttemptAt gate above, so a persistent
		-- fault cannot flood the console.
		warn("[picoo] could not read the place: " .. tostring(snapshot) ..
			" -- the AI will not see your game this turn. Please report this.")
		return
	end

	-- DON'T RE-SEND WHAT DID NOT CHANGE (2026-08-04).
	--
	-- Measured across 30 real snapshots: the average upload is 175 KB and
	-- 113 KB of it (64%) is script sources. This runs every 8 seconds while a
	-- Studio is connected, so each connected session pushes ~1.8 GB a day of
	-- which roughly two thirds is bytes the server already has. Scripts change
	-- rarely; the tree changes constantly.
	--
	-- So hash the sources and, when the hash is unchanged, omit them and set
	-- `scripts_unchanged`. The server carries its stored copy forward (it
	-- already accepts the flag). An empty-string fallback keeps this honest:
	-- if the encode ever fails we send the sources rather than claim they are
	-- unchanged, because a wrong "unchanged" would freeze the AI's view of the
	-- user's code — far worse than the bandwidth it saves.
	local scriptsJson = HttpService:JSONEncode(snapshot.scripts)
	-- Checksum the WHOLE payload, not its length and first 64 bytes.
	--
	-- The old key was `#json .. ":" .. json:sub(1,64)`. Edit a character in place
	-- anywhere past the first script — change a `10` to a `20` — and the length
	-- and prefix both stay identical, so the plugin declares "unchanged" and the
	-- server keeps serving the AI the stale source. That is the "Picoo can't see
	-- my edit" bug, shipped by construction.
	--
	-- Roblox Luau has no built-in digest, so: a rolling checksum over every byte,
	-- plus the length. Cheap (a few ms on a 100KB payload) and it actually changes
	-- when the content does.
	local function checksum(str)
		local h1, h2 = 5381, 52711
		for i = 1, #str do
			local b = string.byte(str, i)
			h1 = (h1 * 33 + b) % 4294967296
			h2 = (h2 * 31 + b) % 4294967296
		end
		return h1 .. "-" .. h2
	end
	local scriptsHash = #scriptsJson .. ":" .. checksum(scriptsJson)
	local unchanged = (lastScriptsHash ~= nil and scriptsHash == lastScriptsHash)

	-- BOTH ENCODES ARE INSIDE A pcall (2026-08-26). They were bare, and
	-- HttpService:JSONEncode throws on a table Roblox cannot represent -- mixed
	-- array/dictionary keys, NaN, inf, a cycle. A throw here escaped into the
	-- poll coroutine and killed it, which used to disable the bridge for the
	-- rest of the Studio session (see BridgeClient.start). The tree is built
	-- from live user content, so we do not get to assume it is encodable.
	local encOk, body = pcall(function()
		local payload = {
			session_id = sessionId,
			place_id = game.PlaceId,
			place_name = game.Name,
			hierarchy = HttpService:JSONEncode(snapshot.tree),
		}
		if unchanged then
			payload.scripts_unchanged = true
		else
			payload.script_sources = snapshot.scripts
		end
		return HttpService:JSONEncode(payload)
	end)
	if not encOk or not body then
		warn("[picoo] could not package your place for upload: " .. tostring(body) ..
			" -- the AI will not see your latest changes. Please report this.")
		return
	end
	-- Skip the upload when the serialized state is byte-identical to what the
	-- server already has (applies to force=true too — a mutation that didn't
	-- change the serialized tree still needs no re-upload).
	if body == lastUploadBody and (now - lastUploadAt) < UNCHANGED_RESEND_INTERVAL then
		return
	end

	local httpOk, response = pcall(function()
		return HttpService:RequestAsync({
			Url = apiBase .. "/hierarchy",
			Method = "POST",
			Headers = {
				["Content-Type"] = "application/json",
				["x-forge-token"] = token,
			},
			Body = body,
		})
	end)
	-- Only advance the success timestamp when the HTTP call actually
	-- went through with a 2xx. 4xx/5xx leave lastUploadAt unchanged,
	-- so the next mutation will trigger another upload immediately.
	if httpOk and response and response.Success then
		lastUploadAt = now
		lastUploadBody = body
		-- A 2xx IS NOT "THE SOURCES ARE STORED" (2026-08-24).
		--
		-- Only remember the hash once the server has actually accepted the
		-- sources — recording it otherwise makes the NEXT call claim
		-- "unchanged" for sources the server never received. `response.Success`
		-- does not test that. Three server paths answer 200 having stored
		-- nothing, all three on purpose: `paused: "no_credits"` (a 402 there
		-- made every zero-credit Studio re-send the same snapshot every 8s
		-- forever — Vercel invocations 625 → 4,400 per 15 min on 2026-08-13),
		-- `skipped: "no_game_id"` / `"place_provision_failed"`, and a swallowed
		-- upsert error. Latch on any of them and the damage is not one turn: the
		-- server, finding no prior row to carry forward, stores `script_sources
		-- = {}`, and `{}` is truthy in JS, so it carries the EMPTY object
		-- forward on every later upload for the rest of the Studio session. The
		-- tree still says hasSource/lines on those nodes, so the model believes
		-- the scripts exist and reads them as blank — then "edits" one by
		-- rewriting it from nothing. That is the same class of damage
		-- MAX_SCRIPT_LENGTH exists to prevent, and it recovers only when the
		-- user edits a script or restarts Studio.
		--
		-- So the accept path now says so outright and we require it. Default is
		-- NOT to latch: an unparseable body, an older server that never sends
		-- the field, or any non-table answer all cost one re-send of the sources
		-- (which lastUploadBody still de-dupes) instead of freezing the AI's
		-- copy of the user's code.
		local stored = false
		pcall(function()
			local decoded = HttpService:JSONDecode(response.Body)
			stored = (type(decoded) == "table") and (decoded.stored == true)
		end)
		if stored then
			lastScriptsHash = scriptsHash
		end
	else
		warn("[picoo] uploadHierarchy failed: " .. tostring(response and response.StatusMessage or "no response"))
	end
end

local function executeCommand(cmd)
	-- pcall on CommandExecutor.execute. It returns three values:
	--   success(bool), result(table?), errMsg(string?)
	-- pcall wraps these into varargs, so we capture them here.
	local pcallOk, success, result, errMsg = pcall(CommandExecutor.execute, cmd)
	if not pcallOk then
		return {
			command_id = cmd.id,
			status = "failed",
			error = "Executor crashed: " .. tostring(success),
		}
	end
	if success then
		return { command_id = cmd.id, status = "completed", result = result }
	else
		return { command_id = cmd.id, status = "failed", error = errMsg or "Unknown error" }
	end
end

function BridgeClient.start()
	if running then return end
	running = true
	stopped = false

	task.spawn(function()
		-- ONE BAD ITERATION MUST NOT END THE SESSION (2026-08-26).
		--
		-- This coroutine used to run the loop body bare. Any uncaught error --
		-- a JSONEncode on an unrepresentable table, an indexing slip on a
		-- malformed command -- killed the coroutine mid-loop, so `running = false`
		-- at the bottom never ran. `running` then stayed true forever, and
		-- BridgeClient.start()'s `if running then return end` turned every later
		-- start into a no-op. The bridge was dead until Studio restarted, and
		-- NOTHING SAID SO: auth still succeeded, the dock still said Connecting,
		-- and Reconnect did nothing at all because it routes back into start().
		--
		-- Measured 2026-08-26 across 172 v1.3 sessions: 84% stopped polling
		-- within two minutes of connecting. The founder reproduced it live --
		-- three reconnects, three sessions, zero polls, commands expiring
		-- "superseded -- timed out before the plugin claimed it".
		--
		-- Each iteration is now isolated. An error is reported and the loop
		-- continues; the bridge degrades for one tick instead of for good.
		local loopOk, loopErr = pcall(function()

		local iterOk, iterErr = pcall(function()
			local startToken = Auth.getSessionToken()
			if startToken then uploadHierarchy(startToken, true) end
		end)
		if not iterOk then
			warn("[picoo] initial place upload failed: " .. tostring(iterErr))
		end

		while not stopped do
			local token = Auth.getSessionToken()
			if not token then
				task.wait(POLL_INTERVAL_MIN)
			else
				-- Throttled background upload (every UPLOAD_INTERVAL sec) to
				-- catch state changes the user makes manually in Studio.
				uploadHierarchy(token, false)

				local commands, err = pollOnce(token)
				if commands == "expired" then
					Auth.clearSession()
					emit("onExpired")
					idlePolls = 0
					task.wait(POLL_INTERVAL_MIN)
				elseif err then
					emit("onError", err)
					idlePolls = 0
					task.wait(POLL_INTERVAL_MIN)
				elseif #commands == 0 then
					idlePolls = idlePolls + 1
					task.wait(currentIdleInterval())
				else
					idlePolls = 0
					-- Execute each, collect results.
					-- CommandExecutor.execute expects { id, type, payload }
					-- where payload is the tool input. The DB row already has
					-- this shape (id, type, payload columns) — just rebuild
					-- with the keys CommandExecutor reads.
					local results = {}
					for _, cmd in ipairs(commands) do
						local cmdType = cmd.type or cmd.command_type
						emit("onCommand", cmdType, cmd.payload)
						-- ui_* events are server→client UI notifications (plan
						-- ticks, chat-complete signals). Bridge plugin doesn't
						-- render UI, so ack as completed and move on.
						if cmdType and cmdType:sub(1, 3) == "ui_" then
							table.insert(results, { command_id = cmd.id, status = "completed", result = { skipped = "ui_event" } })
						else
							table.insert(results, executeCommand({
								id = cmd.id,
								type = cmdType,
								payload = cmd.payload or {},
							}))
						end
					end
					reportResults(token, results)
					-- Mutations just changed the place — push fresh
					-- hierarchy NOW so the next chat turn sees it.
					uploadHierarchy(token, true)
					task.wait(POLL_INTERVAL_BUSY)
				end
			end
		end

		end)

		-- THE GUARD IS RELEASED ON EVERY EXIT PATH -- clean stop, or a throw.
		-- This assignment used to sit inside the loop's own scope, so a thrown
		-- error skipped it and left `running` true forever. start() then
		-- short-circuited on `if running then return end` and the bridge could
		-- never be revived: Reconnect routes into start(), so the button was
		-- inert and only restarting Studio helped. Now a failure costs one
		-- reconnect instead of the session.
		running = false
		if not loopOk then
			warn("[picoo] bridge stopped: " .. tostring(loopErr) ..
				" -- click Reconnect to restart it. Please report this.")
			emit("onError", "bridge loop stopped: " .. tostring(loopErr))
		end
	end)
end

function BridgeClient.stop()
	stopped = true
end

function BridgeClient.isRunning()
	return running
end

return BridgeClient
end)()

--[[ === Module: BridgeClientLocal === ]]
local BridgeClientLocal = (function()
--[[
	BridgeClientLocal — the DESKTOP-mode bridge client for the thin Picoo Studio
	plugin. Drop-in replacement for BridgeClient.lua's cloud long-poll.

	Instead of polling picoo.io (which forced a 2s→45s moderation idle-backoff and
	made builds time out), it polls the Picoo DESKTOP APP over LOCALHOST at ~100ms.
	The desktop app relays commands from the cloud and results/hierarchy back up,
	so the plugin NEVER talks to the cloud → zero moderation concern, sub-second.

	Everything else in the plugin (CommandExecutor.lua, HierarchyReader.lua, the
	validation layer, ChangeHistory wrapping, protected-instance lists) is REUSED
	UNCHANGED. init.server.lua just wires this instead of BridgeClient.

	Pairing/security: the user pastes the short TOKEN shown in the desktop app UI
	into this plugin once (stored via plugin:SetSetting, like the current API key).
	The plugin sends it as x-picoo-token; the desktop bridge is 127.0.0.1-only and
	rejects browser-origin requests, so a website can't drive it.

	Discovery: fixed localhost port range 34872-34875 (Studio plugins can't read
	the desktop's session file from disk). The plugin probes the range on start.
]]

local BridgeClientLocal = {}

local HttpService = nil
local CommandExecutor = nil
local HierarchyReader = nil

local PORT_RANGE = { 34872, 34873, 34874, 34875 }
local baseUrl = nil                 -- resolved on connect, e.g. "http://127.0.0.1:34872"
local token = ""
local robloxUserId = 0              -- reported to the desktop so it can open the cloud session with the RIGHT id
local running = false
local stopped = true

-- Localhost: no bot concern → poll fast. A little slower when idle just to
-- avoid burning CPU; no ramp to 45s like the cloud path.
local POLL_ACTIVE = 0.1
local POLL_IDLE = 0.4
local UPLOAD_INTERVAL = 4.0
local lastUploadAt = 0

local listeners = { onCommand = nil, onError = nil, onConnected = nil, onLost = nil }
local function emit(name, ...)
	local cb = listeners[name]
	if cb then pcall(cb, ...) end
end

function BridgeClientLocal.init(httpService, executorMod, hierarchyMod)
	HttpService = httpService
	CommandExecutor = executorMod
	HierarchyReader = hierarchyMod
end

function BridgeClientLocal.setToken(t)
	token = t or ""
end

function BridgeClientLocal.setRobloxUserId(id)
	robloxUserId = tonumber(id) or 0
end

function BridgeClientLocal.on(event, callback)
	listeners["on" .. event:sub(1, 1):upper() .. event:sub(2)] = callback
end

-- Probe the fixed localhost range for a live Picoo desktop bridge that accepts
-- our token. Returns the base URL or nil.
local function resolveBase()
	for _, port in ipairs(PORT_RANGE) do
		local url = "http://127.0.0.1:" .. port
		local ok, res = pcall(function()
			return HttpService:RequestAsync({
				Url = url .. "/health",
				Method = "GET",
				Headers = { ["x-picoo-token"] = token, ["x-roblox-user-id"] = tostring(robloxUserId) },
			})
		end)
		-- 401 counts as FOUND. Mode detection runs BEFORE we have a token, so
		-- an unauthenticated probe legitimately gets 401 — which still proves
		-- OUR bridge is listening on that port, unlike a refused connection.
		-- Treating 401 as "no app" is exactly why the plugin fell back to the
		-- cloud with the desktop app running (2026-07-29).
		if ok and res and (res.StatusCode == 200 or res.StatusCode == 401) then
			return url
		end
	end
	return nil
end

local function pollOnce()
	local ok, res = pcall(function()
		return HttpService:RequestAsync({
			Url = baseUrl .. "/bridge",
			Method = "GET",
			Headers = { ["x-picoo-token"] = token, ["x-roblox-user-id"] = tostring(robloxUserId) },
		})
	end)
	if not ok then return nil, "request failed: " .. tostring(res) end
	if res.StatusCode == 401 then return nil, "unauthorized" end
	if res.StatusCode ~= 200 then return nil, "HTTP " .. res.StatusCode end
	local decodeOk, parsed = pcall(function() return HttpService:JSONDecode(res.Body) end)
	if not decodeOk or type(parsed) ~= "table" then return nil, "non-JSON body" end
	return parsed.commands or {}
end

local function reportResults(results)
	if #results == 0 then return end
	pcall(function()
		HttpService:RequestAsync({
			Url = baseUrl .. "/bridge",
			Method = "POST",
			Headers = { ["Content-Type"] = "application/json", ["x-picoo-token"] = token },
			Body = HttpService:JSONEncode({ results = results }),
		})
	end)
end

-- Push the Studio tree to the desktop (which relays it to the cloud so the AI's
-- snapshot stays fresh). Same serialize as the cloud plugin; different target.
local function uploadHierarchy(force)
	local now = os.clock()
	if not force and (now - lastUploadAt) < UPLOAD_INTERVAL then return end
	local ok, snapshot = pcall(HierarchyReader.serialize)
	if not ok or not snapshot then
		-- Same silent failure as the cloud transport -- see BridgeClient.lua.
		-- A throw here means the desktop relay uploads nothing, the server sees
		-- a session with no snapshot, and no-snapshot ALLOWS recipes. Throttled
		-- by the UPLOAD_INTERVAL gate above.
		warn("[picoo] could not read the place: " .. tostring(snapshot) ..
			" -- the AI will not see your game this turn. Please report this.")
		return
	end
	local httpOk = pcall(function()
		HttpService:RequestAsync({
			Url = baseUrl .. "/hierarchy",
			Method = "POST",
			Headers = { ["Content-Type"] = "application/json", ["x-picoo-token"] = token },
			Body = HttpService:JSONEncode({
				place_id = game.PlaceId,
				place_name = game.Name,
				hierarchy = HttpService:JSONEncode(snapshot.tree),
				script_sources = snapshot.scripts,
			}),
		})
	end)
	if httpOk then lastUploadAt = now end
end

local function executeCommand(cmd)
	local pcallOk, success, result, errMsg = pcall(CommandExecutor.execute, cmd)
	if not pcallOk then
		return { command_id = cmd.id, status = "failed", error = "Executor crashed: " .. tostring(success) }
	end
	if success then
		return { command_id = cmd.id, status = "completed", result = result }
	end
	return { command_id = cmd.id, status = "failed", error = errMsg or "Unknown error" }
end

function BridgeClientLocal.start()
	if running then return end
	running = true
	stopped = false
	task.spawn(function()
		-- localhost is rock-solid; a single failed poll is a transient blip (Studio
		-- HttpService hiccup), NOT a disconnect. Only sustained failure drops it, so
		-- the "Connected" state never flaps.
		local consecutiveErrors = 0
		local connected = false
		local DROP_AFTER = 4   -- ~1s of continuous failure before we call it lost

		while not stopped do
			if not baseUrl then
				baseUrl = resolveBase()
				if not baseUrl then
					if connected then emit("onLost"); connected = false end
					task.wait(1.0)   -- desktop app not up / wrong token; retry
				else
					consecutiveErrors = 0
					if not connected then emit("onConnected", baseUrl); connected = true end
					uploadHierarchy(true)
				end
			end

			if baseUrl then
				uploadHierarchy(false)
				local commands, err = pollOnce()
				if err then
					consecutiveErrors = consecutiveErrors + 1
					if consecutiveErrors >= DROP_AFTER then
						-- the desktop app really went away → re-resolve
						if connected then emit("onLost"); connected = false end
						baseUrl = nil
						consecutiveErrors = 0
						task.wait(0.5)
					else
						task.wait(0.25)   -- transient blip: keep the connection, retry same base
					end
				else
					consecutiveErrors = 0
					if not connected then emit("onConnected", baseUrl); connected = true end
					if #commands == 0 then
						task.wait(POLL_IDLE)
					else
						local results = {}
						for _, cmd in ipairs(commands) do
							local cmdType = cmd.type or cmd.command_type
							emit("onCommand", cmdType, cmd.payload)
							if cmdType and cmdType:sub(1, 3) == "ui_" then
								table.insert(results, { command_id = cmd.id, status = "completed", result = { skipped = "ui_event" } })
							else
								table.insert(results, executeCommand({ id = cmd.id, type = cmdType, payload = cmd.payload or {} }))
							end
						end
						reportResults(results)
						uploadHierarchy(true)
						task.wait(POLL_ACTIVE)
					end
				end
			end
		end
		running = false
	end)
end

function BridgeClientLocal.stop()
	stopped = true
end

function BridgeClientLocal.isRunning()
	return running
end

-- Tell the desktop app we are leaving, so its UI flips instantly instead of
-- waiting for lastPluginSeen to age out.
function BridgeClientLocal.notifyDisconnect()
	local base = baseUrl or resolveBase()
	if not base then return end
	pcall(function()
		HttpService:RequestAsync({
			Url = base .. "/disconnect",
			Method = "POST",
			Headers = { ["Content-Type"] = "application/json", ["x-picoo-token"] = token },
			Body = "{}",
		})
	end)
end

-- Defined after resolveBase so the local upvalue is visible.
function BridgeClientLocal.probe()
	return resolveBase()
end

return BridgeClientLocal
end)()

--[[ === Module: UI === ]]
local UI = (function()
-- forge-bridge — Plugin UI (Rebirth/Lemonade style, Picoo branding)
--
-- Minimal: 3-bar logo + version + Connect button + Status dot + Disconnect.
-- All chat lives in the browser at picoo.io. Plugin exists ONLY
-- to receive structured commands from Supabase Realtime and apply them.

local UI = {}

-- Set by UI.mount(plugin, widget, version). Single source of truth lives in
-- init.server.lua's PLUGIN_BUILD literal — the build pipeline reads that
-- one number, so duplicating it here would invariably drift.
local PLUGIN_VERSION = "?"

-- Brand palette (matches favicon.svg / logo.png on the website)
local COLOR_EMERALD = Color3.fromRGB(16, 185, 129)   -- #10b981
local COLOR_TEAL    = Color3.fromRGB(11, 184, 170)   -- midpoint
local COLOR_CYAN    = Color3.fromRGB(46, 197, 232)   -- #2ec5e8
local COLOR_BG      = Color3.fromRGB(251, 242, 219)  -- #fbf2db cream page
local COLOR_BG_2    = Color3.fromRGB(255, 255, 255)  -- white card/input
local COLOR_STROKE  = Color3.fromRGB(28, 20, 10)     -- #1c140a thick retro outline
local COLOR_TEXT    = Color3.fromRGB(28, 20, 10)     -- #1c140a ink
local COLOR_MUTED   = Color3.fromRGB(138, 125, 99)   -- #8a7d63 muted brown
local COLOR_OK      = Color3.fromRGB(12, 156, 110)   -- #0c9c6e
local COLOR_WAIT    = Color3.fromRGB(244, 167, 43)   -- #f4a72b

-- Brand logo (Picoo mascot, emerald). Uploaded as a Decal to Roblox;
-- swap this id to rebrand. Used by the dock logo + (init) the toolbar icon.
local LOGO_ASSET_ID = "rbxassetid://129840971342506"

-- State (set externally via UI.setState)
local state = {
	-- Which transport is actually driving right now ("desktop" | "cloud"),
	-- and what the user pinned ("auto" | "desktop" | "cloud"). Declared here
	-- rather than assigned ad-hoc: a table built from a literal is sealed for
	-- the analyzer, so new keys added later read as errors.
	bridgeMode = nil,
	modePreference = "auto",
	connected = false,
	browserOpen = false,
	hasApiKey = false,
	statusOverride = nil,    -- show custom message, e.g. "Connecting…"
	error = nil,
	-- "allowed" | "denied" | "unknown" | nil. When "denied" we surface a
	-- red banner explaining the Studio script-injection permission popup,
	-- because the bridge fails silently otherwise and users see broken
	-- create_script ops with no remediation hint.
	scriptPermission = nil,
	-- Update banner (§2): set true when the server-advertised latest
	-- version differs from this build. Display-only — the plugin can't open
	-- URLs, so we just show the text URL for manual-paste users to copy.
	updateAvailable = false,
	updateUrl = nil,
}

local modeButton
local widget
local frame
local connectButton
local statusDot
local statusLabel
local helpText
local disconnectButton
local apiKeyBox
local saveKeyButton
local keyRow
local permissionBanner
local permissionBannerText
local permissionRetryButton
local updateBanner
local updateBannerText
-- Last key we wrote INTO the box ourselves. Guards against applyState
-- overwriting what the user is mid-way through typing.
local lastAppliedDraft = nil

local function applyState()
	if not frame then return end

	-- Update-available banner (§2). Shown regardless of connection state so
	-- users see it once the server reports a newer version. NO off-site link:
	-- Roblox ToU prohibits visible links in plugin UI (2026-07-14 account
	-- warning), so this stays plain text with no URL in it.
	--
	-- IT MUST NAME A STEP THE READER CAN ACTUALLY TAKE (2026-08-24). This used
	-- to say "update it from Roblox Studio's Manage Plugins", which assumes the
	-- Creator Store install path. Every user who installed by dropping the .lua
	-- into their Plugins folder has NO Manage Plugins entry for it, so the
	-- banner sent them to a screen with nothing on it and then reappeared on
	-- every auth — a permanent nag for an impossible instruction, and a support
	-- ticket. Name the manual replace instead: it is correct for sideload users,
	-- and a Creator Store install auto-updates before this banner would ever be
	-- seen, so nobody is misled by it either way.
	--
	-- LENGTH IS A LAYOUT CONSTRAINT, not a style preference. The frame is a
	-- fixed 44px with 6px of vertical padding — about two lines of TextSize 12 —
	-- and the dock widget's MINIMUM width is 300px (init.server.lua), leaving
	-- ~248px of text width, i.e. ~40 characters per line. TextWrapped clips
	-- rather than growing, so anything past ~80 characters loses its tail on a
	-- narrow dock. This string is 71; the one it replaced was 74. Re-measure
	-- before lengthening it, or grow the frame and move keyRow (y=256) down.
	if updateBanner then
		updateBanner.Visible = state.updateAvailable == true
		if state.updateAvailable and updateBannerText then
			updateBannerText.Text = "New version available — re-download the plugin file and restart Studio."
		end
	end

	-- API key entry state.
	--
	-- `and not state.connected`: the desktop app pairs with a token, not a
	-- key, so a perfectly connected desktop user has hasApiKey=false. Without
	-- this guard the panel drew the whole "Sign in / paste your API key"
	-- screen on top of a live connection (2026-07-29) — the bridge was
	-- running, the app said "Studio connected", and the plugin still asked
	-- for credentials it did not need.
	-- A desktop user never has an API key, so after Disconnect they would be
	-- shown a sign-in form for credentials they do not need. If the app is
	-- reachable the correct offer is simply "Connect".
	if (not state.hasApiKey and not state.connected and not state.desktopReachable) or state.forceKeyEntry then
		statusDot.BackgroundColor3 = COLOR_WAIT
		statusLabel.Text = "Sign in"
		statusLabel.TextColor3 = COLOR_MUTED
		-- SAY WHY, IF WE KNOW WHY (2026-08-26). This branch hardcoded the
		-- generic instruction even when we had just been told exactly what was
		-- wrong with the key — the server's reason went into state.error and
		-- this line threw it away, so "you copied the hidden version of your
		-- key" was displayed as "Paste your API key from your Picoo dashboard."
		-- 66 Roblox accounts read that instruction 1,679 times.
		--
		-- LENGTH IS A LAYOUT CONSTRAINT, exactly as on the update banner above:
		-- this label is a fixed 36px at TextSize 12 and the dock's minimum
		-- width is 300px, so ~3 lines of ~40 characters. Every server message
		-- is capped at 110 characters in lib/forge-key-shape.ts and the gate
		-- test fails the build if one grows past it.
		helpText.Text = state.error or "Paste your API key from your Picoo dashboard."
		-- Put the rejected key BACK in the box. The recovered cohort fixed a
		-- bad paste in a median of 36 seconds once they could see it; retyping
		-- 54 characters from scratch is not that.
		-- An empty draft is meaningful too: "Change API key" clears the field,
		-- and leaving the rejected key sitting in the box after an explicit
		-- reset is how you re-submit it by accident.
		if state.keyDraft ~= nil and state.keyDraft ~= lastAppliedDraft then
			apiKeyBox.Text = state.keyDraft
			lastAppliedDraft = state.keyDraft
		end
		keyRow.Visible = true
		connectButton.Visible = false
		disconnectButton.Visible = false
		if UI._changeKeyButton then UI._changeKeyButton.Visible = false end
		-- Overlap fix (2026-07-16): the permission banner used to stay
		-- visible when returning to the sign-in state and sat at the SAME
		-- Y as the key row — input, banner and button stacked on top of
		-- each other. Always hide it here.
		if permissionBanner then permissionBanner.Visible = false end
		return
	end

	keyRow.Visible = false
	if UI._changeKeyButton then UI._changeKeyButton.Visible = true end

	if state.connected then
		statusDot.BackgroundColor3 = COLOR_OK
		statusLabel.Text = state.statusOverride or "Connected"
		statusLabel.TextColor3 = COLOR_OK
		connectButton.Visible = false
		disconnectButton.Visible = true
		local where = state.bridgeMode == "desktop" and "the desktop app" or "the website"
		helpText.Text = "Connected via " .. where .. " — open Picoo chat to start building."
		-- Script-injection permission banner. Only meaningful once we're
		-- connected (probe runs after auth). 2026-05-16 telemetry: 4 of 5
		-- active users that day had every create_script blocked because
		-- they'd clicked "Don't Allow" on Studio's popup. Without this
		-- banner the bridge fails silently and users blame the product.
		if permissionBanner then
			permissionBanner.Visible = (state.scriptPermission == "denied")
		end
		return
	end
	if permissionBanner then permissionBanner.Visible = false end

	statusDot.BackgroundColor3 = COLOR_WAIT
	statusLabel.Text = state.statusOverride or "Ready to connect"
	statusLabel.TextColor3 = COLOR_MUTED
	connectButton.Visible = true
	disconnectButton.Visible = false
	helpText.Text = state.error or "Open Picoo and click Connect."
end

local function refreshModeLabel()
	if not modeButton then return end
	local pref = state.modePreference or "auto"
	local active = state.bridgeMode
	local suffix = ""
	if pref == "auto" and active then
		suffix = active == "desktop" and " → desktop app" or " → cloud"
	end
	-- User-facing wording: nobody outside this repo knows what a "bridge" or a
	-- "pinned cloud transport" is. They know they are either using the website
	-- or the desktop app, so say that and nothing else.
	-- Say what you ARE on and what tapping DOES. "locked"/"auto" described our
	-- internal state machine, not the user's choice.
	local using = active or ((pref == "desktop" or pref == "cloud") and pref) or nil
	if not using then
		modeButton.Text = "Detecting…"
	elseif using == "desktop" then
		modeButton.Text = "Using: Desktop app   ·   switch to Website"
	else
		modeButton.Text = "Using: Website   ·   switch to Desktop app"
	end
end

function UI.setState(newState)
	for k, v in pairs(newState) do state[k] = v end
	applyState()
	refreshModeLabel()
end

function UI.onConnectClicked(callback)
	UI._connectCallback = callback
end

function UI.onReloadClicked(callback)
	UI._reloadCallback = callback
end

function UI.onDisconnectClicked(callback)
	UI._disconnectCallback = callback
end

function UI.onSaveKeyClicked(callback)
	UI._saveKeyCallback = callback
end

-- "Change API key" was a dead button for desktop users: they have no key, so
-- hasApiKey is false, but desktopReachable is true — and the guard above then
-- refused to draw the key row, so the click did nothing at all (2026-07-29).
function UI.onChangeKeyClicked(callback)
	UI._changeKeyCallback = callback
end

function UI.onPermissionRetryClicked(callback)
	UI._permissionRetryCallback = callback
end

function UI.onModeChanged(callback)
	UI._modeChangedCallback = callback
end

-- Picoo mascot logo (image asset). Falls back to the old 3-bar mark only if
-- the asset id is ever cleared, so the dock never renders an empty box.
local function buildLogo(parent, size)
	local container = Instance.new("Frame")
	container.Size = UDim2.fromOffset(size, size)
	container.BackgroundTransparency = 1
	container.Parent = parent

	if LOGO_ASSET_ID ~= "" then
		local img = Instance.new("ImageLabel")
		img.Size = UDim2.fromScale(1, 1)
		img.BackgroundTransparency = 1
		img.Image = LOGO_ASSET_ID
		img.ScaleType = Enum.ScaleType.Fit
		img.Parent = container
		Instance.new("UICorner", img).CornerRadius = UDim.new(0, math.floor(size * 0.22))
		return container
	end

	local barWidth = math.floor(size * 0.17)
	local barHeight = math.floor(size * 0.75)
	local gap = math.floor(size * 0.085)
	local totalWidth = barWidth * 3 + gap * 2
	local startX = math.floor((size - totalWidth) / 2)
	local barY = math.floor((size - barHeight) / 2)

	local colors = { COLOR_EMERALD, COLOR_TEAL, COLOR_CYAN }
	for i = 1, 3 do
		local bar = Instance.new("Frame")
		bar.Size = UDim2.fromOffset(barWidth, barHeight)
		bar.Position = UDim2.fromOffset(startX + (i - 1) * (barWidth + gap), barY)
		bar.BackgroundColor3 = colors[i]
		bar.BorderSizePixel = 0
		bar.Parent = container
		local corner = Instance.new("UICorner")
		corner.CornerRadius = UDim.new(0, math.floor(barWidth * 0.5))
		corner.Parent = bar
	end

	return container
end

function UI.mount(plugin, dockWidget, version)
	widget = dockWidget
	if version then PLUGIN_VERSION = version end

	frame = Instance.new("Frame")
	frame.Size = UDim2.new(1, 0, 1, 0)
	frame.BackgroundColor3 = COLOR_BG
	frame.BorderSizePixel = 0
	frame.Parent = widget

	-- Subtle radial-ish gradient using UIGradient on a background layer
	local bgGradient = Instance.new("UIGradient")
	bgGradient.Color = ColorSequence.new({
		ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 250, 235)),
		ColorSequenceKeypoint.new(1, COLOR_BG),
	})
	bgGradient.Rotation = 135
	bgGradient.Parent = frame

	-- Logo block centered top
	local logoBlock = Instance.new("Frame")
	logoBlock.Size = UDim2.new(1, 0, 0, 110)
	logoBlock.Position = UDim2.fromOffset(0, 18)
	logoBlock.BackgroundTransparency = 1
	logoBlock.Parent = frame

	local logoBox = Instance.new("Frame")
	logoBox.Size = UDim2.fromOffset(72, 72)
	logoBox.Position = UDim2.new(0.5, -36, 0, 0)
	logoBox.BackgroundColor3 = Color3.fromRGB(216, 247, 233)
	logoBox.BorderSizePixel = 0
	logoBox.Parent = logoBlock
	Instance.new("UICorner", logoBox).CornerRadius = UDim.new(0, 18)
	local logoStroke = Instance.new("UIStroke", logoBox)
	logoStroke.Color = COLOR_STROKE
	logoStroke.Thickness = 3
	buildLogo(logoBox, 72)

	local title = Instance.new("TextLabel")
	title.Size = UDim2.new(1, 0, 0, 28)
	title.Position = UDim2.new(0, 0, 0, 80)
	title.BackgroundTransparency = 1
	title.Text = "Picoo"
	title.Font = Enum.Font.LuckiestGuy
	title.TextSize = 26
	title.TextColor3 = COLOR_TEXT
	title.Parent = logoBlock

	-- Status row (centered)
	local statusRow = Instance.new("Frame")
	statusRow.Size = UDim2.new(1, -32, 0, 22)
	statusRow.Position = UDim2.fromOffset(16, 138)
	statusRow.BackgroundTransparency = 1
	statusRow.Parent = frame

	local statusLayout = Instance.new("UIListLayout")
	statusLayout.FillDirection = Enum.FillDirection.Horizontal
	statusLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
	statusLayout.VerticalAlignment = Enum.VerticalAlignment.Center
	statusLayout.Padding = UDim.new(0, 8)
	statusLayout.Parent = statusRow

	statusDot = Instance.new("Frame")
	statusDot.Size = UDim2.fromOffset(8, 8)
	statusDot.BackgroundColor3 = COLOR_WAIT
	statusDot.BorderSizePixel = 0
	statusDot.LayoutOrder = 1
	statusDot.Parent = statusRow
	Instance.new("UICorner", statusDot).CornerRadius = UDim.new(1, 0)

	statusLabel = Instance.new("TextLabel")
	statusLabel.Size = UDim2.fromOffset(220, 22)
	statusLabel.AutomaticSize = Enum.AutomaticSize.X
	statusLabel.BackgroundTransparency = 1
	statusLabel.Text = "Waiting for browser…"
	statusLabel.Font = Enum.Font.GothamMedium
	statusLabel.TextSize = 13
	statusLabel.TextColor3 = COLOR_MUTED
	statusLabel.LayoutOrder = 2
	statusLabel.Parent = statusRow

	-- Help text
	helpText = Instance.new("TextLabel")
	helpText.Size = UDim2.new(1, -32, 0, 36)
	helpText.Position = UDim2.fromOffset(16, 204)
	helpText.BackgroundTransparency = 1
	helpText.Text = "Open Picoo and click Connect to link this Studio session."
	helpText.Font = Enum.Font.Gotham
	helpText.TextSize = 12
	helpText.TextColor3 = COLOR_MUTED
	helpText.TextXAlignment = Enum.TextXAlignment.Center
	helpText.TextWrapped = true
	helpText.Parent = frame

	-- Bridge mode selector. One plugin serves BOTH transports (the desktop
	-- app's local bridge and picoo.io), so the user needs to see which one is
	-- driving and be able to pin it when auto-detection is not what they want
	-- — e.g. debugging the cloud path with the app open. Cycles rather than
	-- opening a menu: three states do not deserve a dropdown in a 340px dock.
	modeButton = Instance.new("TextButton")
	modeButton.Size = UDim2.new(0, 280, 0, 30)
	modeButton.Position = UDim2.new(0.5, -140, 0, 166)
	modeButton.BackgroundColor3 = Color3.fromRGB(255, 250, 235)
	modeButton.BorderSizePixel = 0
	modeButton.Text = "Detecting…"
	modeButton.Font = Enum.Font.GothamBold
	modeButton.TextSize = 12
	modeButton.TextColor3 = COLOR_STROKE
	modeButton.AutoButtonColor = false
	modeButton.Parent = frame
	Instance.new("UICorner", modeButton).CornerRadius = UDim.new(0, 15)
	local modeStroke = Instance.new("UIStroke", modeButton)
	modeStroke.Color = COLOR_STROKE
	modeStroke.Thickness = 3
	-- On a TextButton, UIStroke defaults to Contextual, which strokes the
	-- GLYPHS — a 3px outline on 12px text renders as an unreadable black
	-- blob (2026-07-29). Border is what "chunky retro outline" means here.
	modeStroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Border

	modeButton.Activated:Connect(function()
		-- Two states, not three. "auto" only decides the FIRST value on a fresh
		-- install; once the user touches this control they are choosing a
		-- destination, and a three-way cycle through an invisible "auto" state
		-- just made them tap twice and read "locked" (founder, 2026-07-29).
		local current = state.modePreference
		if current ~= "desktop" and current ~= "cloud" then
			current = (state.bridgeMode == "desktop") and "desktop" or "cloud"
		end
		local nextMode = current == "desktop" and "cloud" or "desktop"
		state.modePreference = nextMode
		applyState()
		if UI._modeChangedCallback then UI._modeChangedCallback(nextMode) end
	end)

	-- Script-injection permission banner. Hidden by default, surfaced
	-- only when probeScriptInjection() returned "denied". Sized so it
	-- stacks below helpText without overlapping the API key row (which
	-- is only visible when disconnected anyway).
	local BANNER_RED = Color3.fromRGB(220, 80, 80)
	local BANNER_BG  = Color3.fromRGB(46, 22, 22)
	permissionBanner = Instance.new("Frame")
	permissionBanner.Size = UDim2.new(1, -32, 0, 96)
	-- y=312: BELOW the button stack (connect/disconnect at 218 + change-key at
	-- 262). It used to sit at 210 — the exact same Y as the key row and the
	-- buttons — so the banner, input and buttons rendered on top of each other.
	permissionBanner.Position = UDim2.fromOffset(16, 336)
	permissionBanner.BackgroundColor3 = BANNER_BG
	permissionBanner.BorderSizePixel = 0
	permissionBanner.Visible = false
	permissionBanner.Parent = frame
	Instance.new("UICorner", permissionBanner).CornerRadius = UDim.new(0, 8)
	local bannerStroke = Instance.new("UIStroke", permissionBanner)
	bannerStroke.Color = BANNER_RED
	bannerStroke.Thickness = 1
	bannerStroke.Transparency = 0.5
	local bannerPadding = Instance.new("UIPadding", permissionBanner)
	bannerPadding.PaddingLeft = UDim.new(0, 12)
	bannerPadding.PaddingRight = UDim.new(0, 12)
	bannerPadding.PaddingTop = UDim.new(0, 10)
	bannerPadding.PaddingBottom = UDim.new(0, 10)

	permissionBannerText = Instance.new("TextLabel")
	permissionBannerText.Size = UDim2.new(1, 0, 0, 50)
	permissionBannerText.Position = UDim2.fromOffset(0, 0)
	permissionBannerText.BackgroundTransparency = 1
	permissionBannerText.Text = "Studio blocked script injection.\nFix: Plugins menu > Picoo > Allow script edits."
	permissionBannerText.Font = Enum.Font.Gotham
	permissionBannerText.TextSize = 12
	permissionBannerText.TextColor3 = BANNER_RED
	permissionBannerText.TextXAlignment = Enum.TextXAlignment.Left
	permissionBannerText.TextYAlignment = Enum.TextYAlignment.Top
	permissionBannerText.TextWrapped = true
	permissionBannerText.Parent = permissionBanner

	permissionRetryButton = Instance.new("TextButton")
	permissionRetryButton.Size = UDim2.new(1, 0, 0, 24)
	permissionRetryButton.Position = UDim2.new(0, 0, 1, -24)
	permissionRetryButton.AnchorPoint = Vector2.new(0, 1)
	permissionRetryButton.BackgroundColor3 = BANNER_RED
	permissionRetryButton.Text = "I clicked Allow — retry"
	permissionRetryButton.Font = Enum.Font.GothamBold
	permissionRetryButton.TextSize = 11
	permissionRetryButton.TextColor3 = Color3.fromRGB(8, 8, 8)
	permissionRetryButton.BorderSizePixel = 0
	permissionRetryButton.Parent = permissionBanner
	Instance.new("UICorner", permissionRetryButton).CornerRadius = UDim.new(0, 6)
	permissionRetryButton.MouseButton1Click:Connect(function()
		if UI._permissionRetryCallback then UI._permissionRetryCallback() end
	end)

	-- Update-available banner (§2). Emerald, display-only. Same layout
	-- pattern as permissionBanner but shorter (no retry button — the
	-- plugin can't open URLs, so we just show the text URL to copy).
	local UPDATE_BG = Color3.fromRGB(20, 44, 36)
	updateBanner = Instance.new("Frame")
	updateBanner.Size = UDim2.new(1, -32, 0, 44)
	updateBanner.Position = UDim2.fromOffset(16, 204)
	updateBanner.BackgroundColor3 = UPDATE_BG
	updateBanner.BorderSizePixel = 0
	updateBanner.Visible = false
	updateBanner.ZIndex = 5
	updateBanner.Parent = frame
	Instance.new("UICorner", updateBanner).CornerRadius = UDim.new(0, 8)
	local updateStroke = Instance.new("UIStroke", updateBanner)
	updateStroke.Color = COLOR_EMERALD
	updateStroke.Thickness = 1
	updateStroke.Transparency = 0.3
	local updatePadding = Instance.new("UIPadding", updateBanner)
	updatePadding.PaddingLeft = UDim.new(0, 10)
	updatePadding.PaddingRight = UDim.new(0, 10)
	updatePadding.PaddingTop = UDim.new(0, 6)
	updatePadding.PaddingBottom = UDim.new(0, 6)

	updateBannerText = Instance.new("TextLabel")
	updateBannerText.Size = UDim2.new(1, 0, 1, 0)
	updateBannerText.BackgroundTransparency = 1
	-- Keep in sync with applyState's copy above — that one is what the user
	-- actually reads, and see it for the 80-character layout ceiling. (2026-08-24.)
	updateBannerText.Text = "New version available — re-download the plugin file and restart Studio."
	updateBannerText.Font = Enum.Font.GothamMedium
	updateBannerText.TextSize = 12
	updateBannerText.TextColor3 = COLOR_EMERALD
	updateBannerText.TextXAlignment = Enum.TextXAlignment.Left
	updateBannerText.TextYAlignment = Enum.TextYAlignment.Center
	updateBannerText.TextWrapped = true
	updateBannerText.ZIndex = 6
	updateBannerText.Parent = updateBanner

	-- API key row (visible when not authenticated)
	keyRow = Instance.new("Frame")
	keyRow.Size = UDim2.new(1, -32, 0, 76)
	keyRow.Position = UDim2.fromOffset(16, 256)
	keyRow.BackgroundTransparency = 1
	keyRow.Visible = false
	keyRow.Parent = frame

	apiKeyBox = Instance.new("TextBox")
	apiKeyBox.Size = UDim2.new(1, 0, 0, 36)
	apiKeyBox.Position = UDim2.fromOffset(0, 0)
	apiKeyBox.ClipsDescendants = true          -- a 50-char key ran past both edges
	apiKeyBox.TextXAlignment = Enum.TextXAlignment.Left
	apiKeyBox.TextSize = 12
	local keyPad = Instance.new("UIPadding", apiKeyBox)
	keyPad.PaddingLeft = UDim.new(0, 12)
	keyPad.PaddingRight = UDim.new(0, 12)
	apiKeyBox.BackgroundColor3 = COLOR_BG_2
	apiKeyBox.PlaceholderText = "Paste your API key"
	apiKeyBox.PlaceholderColor3 = COLOR_MUTED
	apiKeyBox.Text = ""
	apiKeyBox.TextSize = 13
	apiKeyBox.Font = Enum.Font.RobotoMono
	apiKeyBox.TextColor3 = COLOR_TEXT
	apiKeyBox.ClearTextOnFocus = false
	apiKeyBox.BorderSizePixel = 0
	apiKeyBox.Parent = keyRow
	Instance.new("UICorner", apiKeyBox).CornerRadius = UDim.new(0, 8)
	local keyPadding = Instance.new("UIPadding", apiKeyBox)
	keyPadding.PaddingLeft = UDim.new(0, 12)
	keyPadding.PaddingRight = UDim.new(0, 12)

	saveKeyButton = Instance.new("TextButton")
	saveKeyButton.Size = UDim2.new(1, 0, 0, 32)
	saveKeyButton.Position = UDim2.fromOffset(0, 44)
	saveKeyButton.BackgroundColor3 = COLOR_EMERALD
	saveKeyButton.Text = "Sign in"
	saveKeyButton.Font = Enum.Font.GothamBold
	saveKeyButton.TextSize = 13
	saveKeyButton.TextColor3 = Color3.fromRGB(255, 255, 255)
	saveKeyButton.BorderSizePixel = 0
	saveKeyButton.Parent = keyRow
	Instance.new("UICorner", saveKeyButton).CornerRadius = UDim.new(0, 8)
	local saveGradient = Instance.new("UIGradient")
	saveGradient.Color = ColorSequence.new(COLOR_EMERALD, COLOR_EMERALD)
	saveGradient.Rotation = 90
	saveGradient.Parent = saveKeyButton

	saveKeyButton.Activated:Connect(function()
		local key = apiKeyBox.Text:gsub("^%s+", ""):gsub("%s+$", "")
		if #key < 8 then
			helpText.Text = "Key looks too short — copy the full key."
			return
		end
		if UI._saveKeyCallback then UI._saveKeyCallback(key) end
	end)

	-- Connect button (gradient, brand)
	connectButton = Instance.new("TextButton")
	connectButton.Size = UDim2.new(1, -32, 0, 40)
	connectButton.Position = UDim2.fromOffset(16, 256)
	connectButton.BackgroundColor3 = COLOR_EMERALD
	connectButton.Text = "Connect"
	connectButton.Font = Enum.Font.GothamBold
	connectButton.TextSize = 14
	connectButton.TextColor3 = Color3.fromRGB(255, 255, 255)
	connectButton.BorderSizePixel = 0
	connectButton.AutoButtonColor = true
	connectButton.Parent = frame
	Instance.new("UICorner", connectButton).CornerRadius = UDim.new(0, 10)

	local btnGradient = Instance.new("UIGradient")
	btnGradient.Color = ColorSequence.new(COLOR_EMERALD, COLOR_EMERALD)
	btnGradient.Rotation = 90
	btnGradient.Parent = connectButton

	connectButton.Activated:Connect(function()
		if UI._connectCallback then UI._connectCallback() end
	end)

	-- Disconnect button (subtle, secondary) — LEFT half; Reconnect sits right.
	disconnectButton = Instance.new("TextButton")
	disconnectButton.Size = UDim2.new(0.5, -20, 0, 36)
	disconnectButton.Position = UDim2.fromOffset(16, 256)
	disconnectButton.BackgroundColor3 = COLOR_BG_2
	disconnectButton.Text = "Disconnect"
	disconnectButton.Font = Enum.Font.GothamMedium
	disconnectButton.TextSize = 13
	disconnectButton.TextColor3 = COLOR_MUTED
	disconnectButton.BorderSizePixel = 0
	disconnectButton.Visible = false
	disconnectButton.Parent = frame
	Instance.new("UICorner", disconnectButton).CornerRadius = UDim.new(0, 10)

	disconnectButton.Activated:Connect(function()
		if UI._disconnectCallback then UI._disconnectCallback() end
	end)

	-- Reconnect button (v1.2.1) — one tap instead of Disconnect→Connect.
	-- Founder request 2026-07-16: a stuck session was only fixable by the
	-- two-step dance; this does stop→clear→re-auth in one click.
	UI._reloadButton = Instance.new("TextButton")
	UI._reloadButton.Size = UDim2.new(0.5, -20, 0, 36)
	UI._reloadButton.Position = UDim2.new(0.5, 4, 0, 256)
	UI._reloadButton.BackgroundColor3 = COLOR_EMERALD
	UI._reloadButton.Text = "Reconnect"
	UI._reloadButton.Font = Enum.Font.GothamBold
	UI._reloadButton.TextSize = 13
	UI._reloadButton.TextColor3 = Color3.fromRGB(255, 255, 255)
	UI._reloadButton.BorderSizePixel = 0
	UI._reloadButton.Visible = false
	UI._reloadButton.Parent = frame
	Instance.new("UICorner", UI._reloadButton).CornerRadius = UDim.new(0, 10)

	UI._reloadButton.Activated:Connect(function()
		if UI._reloadCallback then UI._reloadCallback() end
	end)

	-- Keep Reconnect's visibility permanently in lockstep with Disconnect —
	-- one signal instead of touching every applyState branch.
	disconnectButton:GetPropertyChangedSignal("Visible"):Connect(function()
		UI._reloadButton.Visible = disconnectButton.Visible
	end)

	-- "Change API key" small text button (only visible when has key)
	local changeKeyButton = Instance.new("TextButton")
	changeKeyButton.Name = "ChangeKey"
	changeKeyButton.Size = UDim2.new(1, -32, 0, 18)
	-- ANCHORED TO THE BOTTOM, NOT TO y=306 (2026-08-26). The widget is created
	-- with DockWidgetPluginGuiInfo.new(..., 340, 320, 300, 280) — 320px tall by
	-- default, 280 minimum — and the footer below sits at (1, -28). An absolute
	-- y=306 with height 18 therefore ran 306-324: overlapping the footer at the
	-- default size and entirely off the widget at the minimum. This is the ONLY
	-- route back to the sign-in form for a user whose saved key does not work,
	-- and it was unreachable for anyone who had not dragged the panel taller.
	changeKeyButton.Position = UDim2.new(0, 16, 1, -52)
	changeKeyButton.BackgroundTransparency = 1
	changeKeyButton.Text = "Change API key"
	changeKeyButton.Font = Enum.Font.Gotham
	changeKeyButton.TextSize = 11
	changeKeyButton.TextColor3 = COLOR_MUTED
	changeKeyButton.AutoButtonColor = false
	changeKeyButton.Parent = frame
	UI._changeKeyButton = changeKeyButton

	changeKeyButton.MouseEnter:Connect(function()
		changeKeyButton.TextColor3 = COLOR_TEXT
	end)
	changeKeyButton.MouseLeave:Connect(function()
		changeKeyButton.TextColor3 = COLOR_MUTED
	end)
	changeKeyButton.Activated:Connect(function()
		if UI._changeKeyCallback then UI._changeKeyCallback() end
	end)

	-- Footer: version + dashboard link
	local footer = Instance.new("Frame")
	footer.Size = UDim2.new(1, -32, 0, 18)
	footer.Position = UDim2.new(0, 16, 1, -28)
	footer.BackgroundTransparency = 1
	footer.Parent = frame

	local versionLabel = Instance.new("TextLabel")
	versionLabel.Size = UDim2.new(0.5, 0, 1, 0)
	versionLabel.BackgroundTransparency = 1
	versionLabel.Text = "v" .. PLUGIN_VERSION
	versionLabel.Font = Enum.Font.Gotham
	versionLabel.TextSize = 11
	versionLabel.TextColor3 = COLOR_MUTED
	versionLabel.TextXAlignment = Enum.TextXAlignment.Left
	versionLabel.Parent = footer

	local dashLink = Instance.new("TextLabel")
	dashLink.Size = UDim2.new(0.5, 0, 1, 0)
	dashLink.Position = UDim2.fromScale(0.5, 0)
	dashLink.BackgroundTransparency = 1
	dashLink.Text = "picoo"
	dashLink.Font = Enum.Font.Gotham
	dashLink.TextSize = 11
	dashLink.TextColor3 = COLOR_MUTED
	dashLink.TextXAlignment = Enum.TextXAlignment.Right
	dashLink.Parent = footer

	applyState()
end

return UI
end)()

--[[ ================= ENTRY (init.server.lua) ================= ]]

-- forge-bridge entry point
--
-- Wires UI ↔ Auth ↔ BridgeClient ↔ CommandExecutor.
-- Bridge plugin = dumb executor (Rojo pattern). Browser hosts chat at
-- picoo.io/chat — this plugin only receives structured ops via
-- /api/forge/bridge polling and applies them to Studio.

-- ⚠️ Guard: only run inside the Studio plugin context. The `plugin` global
-- only exists when Studio loads this file from the Plugins folder. If the
-- built .lua file ends up inside a place's DataModel as a Script (which
-- happens when users drag-drop or Live Editing duplicates it), we'd race
-- the real plugin's auth and burn its session_token. Bail early.
if not plugin then
	-- silent — don't spam Output for every player on every Run
	return
end

-- During Play (F5), Studio loads this plugin into BOTH the server and client
-- play DataModels. The client VM can't make HTTP requests ("Http requests can
-- only be executed by game server"), so it spammed `uploadHierarchy failed`
-- every 2s (2026-06-07). The bridge only needs the edit/server context; bail
-- on the running client so it never touches HttpService.
local RunService = game:GetService("RunService")
if RunService:IsRunning() and not RunService:IsServer() then
	return
end

-- 1.7 (2026-08-26): a key is written to plugin settings only AFTER the server
-- accepts it, a credential failure puts the paste box back with the rejected
-- key still in it, the panel shows the server's actual reason instead of a flat
-- "Invalid API key", and "Change API key" is anchored above the footer instead
-- of at y=306 where a 320px-tall widget clipped it.
--
-- ⚠️ RELEASE COUPLING: bumping this alone does nothing. latest_plugin_version
-- in src/app/api/forge/auth/route.ts must flip to "1.7" in the SAME deploy that
-- ships a rebuilt public/plugin/picoo.server.lua — flip the server first and
-- every 1.6 user is told to update to a build that is not there yet; flip it
-- never and no 1.6 user is ever told there is a fix. Neither has been done
-- here: publishing is the founder's call.
local PLUGIN_BUILD = "picoo 1.7"
local API_BASE = "https://picoo.io/api/forge"

local HttpService = game:GetService("HttpService")
local StudioService = game:GetService("StudioService")
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local ScriptEditorService = game:GetService("ScriptEditorService")
local LogService = game:GetService("LogService")

local SESSION_ID = HttpService:GenerateGUID(false)

-- ============================================================
-- Toolbar + dock widget
-- ============================================================
local toolbar = plugin:CreateToolbar("Picoo")

-- STAGED v1.2.1: icon 129840971342506 fails to load in real Studios
-- ("Unable to load plugin icon", 1082 runtime errors / 48h — asset unusable).
-- Empty icon = no failed image load = no console error. Swap in a valid
-- uploaded icon before publishing this batch if a branded one is ready.
local toggleButton = toolbar:CreateButton(
	"Picoo",
	"Connect Studio to Picoo",
	""
)
toggleButton.ClickableWhenViewportHidden = true

local widgetInfo = DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Right,
	false, false,
	340, 320, 300, 280
)

local widget = plugin:CreateDockWidgetPluginGui("Picoo_Widget", widgetInfo)
widget.Title = "Picoo"
widget.Name = "Picoo"

-- ============================================================
-- One-shot cleanup: kill any duplicate forge-bridge Script that ended up
-- inside the user's place (drag-drop, Live Editing copy, Studio autosave).
-- Those duplicates run on Server/Client during Run mode, spam Output, race
-- session_token. Even with the `if not plugin then return end` guard in
-- the latest build, an OLDER copy without the guard keeps misbehaving.
-- We hunt by signature: the unique PLUGIN_BUILD string lives only in our
-- generated files. Plain Scripts with that string are duplicates.
-- ============================================================
do
	-- Match the build-header line that EVERY generated plugin build starts
	-- with, but no hand-written user script would ever contain. The old
	-- signature "forge-bridge" was a loose substring that ALSO destroyed any
	-- user/AI script merely MENTIONING that string in a comment — a real
	-- "the plugin deleted my script" bug. This header is specific to our
	-- generated single-file builds and still matches every version.
	local SIGNATURE = "-- picoo (single-file build)"
	local roots = {
		game:GetService("Workspace"),
		game:GetService("ServerScriptService"),
		game:GetService("ServerStorage"),
		game:GetService("ReplicatedStorage"),
		game:GetService("StarterPlayer"),
		game:GetService("StarterGui"),
		game:GetService("StarterPack"),
	}
	local removed = 0
	for _, root in ipairs(roots) do
		for _, descendant in ipairs(root:GetDescendants()) do
			if (descendant:IsA("Script") or descendant:IsA("LocalScript") or descendant:IsA("ModuleScript")) then
				local ok, src = pcall(function() return descendant.Source end)
				if ok and type(src) == "string" and src:find(SIGNATURE, 1, true) then
					pcall(function() descendant:Destroy() end)
					removed = removed + 1
				end
			end
		end
	end
	if removed > 0 then
		print("[picoo] Cleaned up " .. removed .. " stale duplicate Script(s) from this place")
	end
end

-- ============================================================
-- Init modules
-- ============================================================
local SHORT_VERSION = PLUGIN_BUILD:match("picoo%s+(.+)") or PLUGIN_BUILD
Auth.init(plugin, HttpService, API_BASE, SHORT_VERSION)
CommandExecutor.init(ChangeHistoryService, ScriptEditorService, HttpService)
BridgeClient.init(HttpService, API_BASE, SESSION_ID, Auth, CommandExecutor)
BridgeClientLocal.init(HttpService, CommandExecutor, HierarchyReader)
UI.mount(plugin, widget, SHORT_VERSION)
UI.setState({ bridgeMode = nil, modePreference = (plugin:GetSetting("picoo_bridge_mode") or "auto") })

local function getRobloxUserId()
	local ok, uid = pcall(function() return StudioService:GetUserId() end)
	return (ok and uid and uid ~= 0) and uid or 0
end

-- BridgeClient telemetry → status row updates
BridgeClient.on("command", function(cmdType)
	UI.setState({ statusOverride = "Running " .. tostring(cmdType) .. "…" })
	-- Reset shortly to "Connected" so status doesn't get stuck
	task.delay(1.5, function()
		if BridgeClient.isRunning() then
			UI.setState({ statusOverride = nil })
		end
	end)
end)

BridgeClient.on("error", function(errMsg)
	warn("[picoo] Bridge error: " .. tostring(errMsg))
end)

-- FORWARD DECLARATION — load-bearing, do not turn this back into a plain
-- `local function` at the definition site (2026-08-24).
--
-- The "expired" handler below calls tryAuth, but tryAuth was defined with
-- `local function tryAuth` ~190 lines LOWER. Lua resolves a name when the
-- closure is CREATED, so this handler captured a GLOBAL named tryAuth that is
-- never assigned — i.e. nil. Every 401 therefore ran `nil("auto-reauth")` and
-- threw inside the callback instead of re-authenticating.
--
-- The effect on a paying user: the server expires their session, the plugin
-- prints "Session expired — re-authenticating", the re-auth dies silently, and
-- the poll loop keeps running against a dead token. The dock still says
-- Connected. Nothing in Studio Output says what went wrong. They conclude the
-- product stopped working, which it did.
--
-- Found 2026-08-24 by luau-analyze ("Unknown global 'tryAuth'"), not by
-- reading — the shape is invisible at a glance and had been shipping since at
-- least picoo 1.3. The other three call sites (save-key, connect-button,
-- reload-button) all sit BELOW the definition and were always fine, which is
-- why this only ever bit users mid-session and never during setup.
local tryAuth

-- Server returned 401 — silently re-auth and resume polling. No "please
-- reconnect" prompt to the user; the cached forge_key is enough.
BridgeClient.on("expired", function()
	print("[picoo] Session expired — re-authenticating with cached key")
	-- Do NOT stop() here. stop() sets stopped=true and the still-running poll
	-- loop then exits, but start() (called by tryAuth) early-returns because
	-- `running` is still true at that instant — leaving ZERO poll loops with
	-- the UI still showing "Connected". Instead just re-auth: the existing,
	-- still-running loop picks up the new session token on its next iteration.
	tryAuth("auto-reauth")
end)

-- Probe Studio's script-injection permission once and push the result
-- into UI state. Called right after BridgeClient.start() and again when
-- the user clicks the banner's Retry button. Probe is cheap (creates +
-- destroys a Script in ServerStorage), runs in pcall so any unexpected
-- failure never breaks the connect flow. 2026-05-16 telemetry: 4 of 5
-- active users had every create_script silently fail on permission
-- denial — this surfaces the actual remediation step.
local function runPermissionProbe()
	local status, _err = CommandExecutor.probeScriptInjection()
	UI.setState({ scriptPermission = status })
	print("[picoo] script_permission=" .. tostring(status))
	-- Push probe result to server so the chat route can pre-flight
	-- strip create_script / update_script BEFORE the model even tries
	-- to call them. Previously the server only learned the user's
	-- permission state by observing a FAILED bridge_command — by
	-- which point parallel emits had already burned 2-4 credits.
	local token = Auth.getSessionToken()
	if token then
		pcall(function()
			HttpService:RequestAsync({
				Url = API_BASE .. "/script-permission",
				Method = "POST",
				Headers = {
					["Content-Type"] = "application/json",
					["x-forge-token"] = token,
				},
				Body = HttpService:JSONEncode({
					session_id = SESSION_ID,
					script_permission = status,
				}),
			})
		end)
	end
end

-- ============================================================
-- BRIDGE MODE — one plugin, two transports
-- ============================================================
-- There used to be TWO plugins: this one (polls picoo.io) and a separate
-- "PicooDesktop" build (polls 127.0.0.1). A user could end up with both
-- installed, two Picoo panels in Studio, and no way to know which one was
-- driving. Same executor, same commands — only the transport differed, so
-- they are now one plugin that picks the transport at runtime.
--
-- The desktop app's local bridge accepts the user's Picoo API key as its
-- token (bridge-server.js: `token === this.token || token === this.forgeKey`),
-- so the SAME key the user already pasted authenticates both transports.
-- Nothing to copy, no pairing code, no second install.
--
-- DELIBERATELY DUMB: this is the only decision the plugin makes on its own,
-- and it is a loopback /health probe that cannot change shape. Everything
-- else — what to build, which commands exist, how they are ordered — comes
-- from the server, so shipping product changes never means shipping a plugin.
local MODE_SETTING = "picoo_bridge_mode"   -- "auto" | "cloud" | "desktop"
local activeTransport = nil                -- BridgeClient | BridgeClientLocal
local desktopPaused = false                -- user hit Disconnect; stay off until they ask
local desktopWatcher = nil

local function getMode()
	local m = plugin:GetSetting(MODE_SETTING)
	if m == "cloud" or m == "desktop" then return m end
	return "auto"
end

local function desktopAvailable()
	local ok, base = pcall(BridgeClientLocal.probe)
	local found = (ok and base) or nil
	-- The UI needs this even when we are NOT connecting: a disconnected
	-- desktop user must be offered "Connect", not an API-key form.
	UI.setState({ desktopReachable = found ~= nil })
	return found
end

local function stopTransports()
	pcall(function() BridgeClient.stop() end)
	pcall(function() BridgeClientLocal.stop() end)
	activeTransport = nil
end

-- The desktop app stamps its CURRENT bridge token over this placeholder every
-- time it boots (main.js syncPluginToken), which is what lets a fresh desktop
-- user connect without ever seeing a key: app opens → token baked in → Studio
-- opens → connected. Cloud mode never reads it.
local EMBEDDED_TOKEN = "__PICOO_EMBEDDED_TOKEN__"

local function startLocal()
	-- The local bridge accepts EITHER its own token or the user's Picoo API
	-- key, so prefer the key (survives app restarts) and fall back to the
	-- stamped token for users who have not pasted a key at all.
	local key = Auth.getApiKey()
	local token = (key ~= "" and key) or (EMBEDDED_TOKEN:sub(1, 6) == "picoo-" and EMBEDDED_TOKEN or "")
	BridgeClientLocal.setToken(token)
	BridgeClientLocal.setRobloxUserId(getRobloxUserId())
	if not BridgeClientLocal.isRunning() then BridgeClientLocal.start() end
	activeTransport = "desktop"
	UI.setState({ connected = true, statusOverride = nil, error = nil, bridgeMode = "desktop" })
	print("[picoo] Bridge mode: DESKTOP APP (local, 127.0.0.1)")
end

local function startCloud()
	BridgeClient.start()
	activeTransport = "cloud"
	UI.setState({ connected = true, statusOverride = nil, error = nil, bridgeMode = "cloud" })
	print("[picoo] Bridge mode: CLOUD (picoo.io)")
end

-- In auto mode keep watching: launching or quitting the desktop app mid-session
-- should move the plugin over without the user touching anything.
local function startDesktopWatcher()
	if desktopWatcher then return end
	desktopWatcher = task.spawn(function()
		while true do
			task.wait(20)
			if desktopPaused or getMode() ~= "auto" or not activeTransport then
				-- nothing to arbitrate
			elseif activeTransport == "cloud" and desktopAvailable() then
				print("[picoo] Desktop app detected — switching to the local bridge")
				stopTransports()
				startLocal()
			elseif activeTransport == "desktop" and not desktopAvailable() then
				print("[picoo] Desktop app closed — falling back to the cloud bridge")
				stopTransports()
				startCloud()
			end
		end
	end)
end

-- Tell BOTH sides we are going dark. The cloud infers connectivity from a 75s
-- poll window (right for silence, wrong for an intentional goodbye), and the
-- desktop app ages out its own lastPluginSeen — so an explicit Disconnect left
-- "Studio connected" on screen for over a minute (2026-07-29).
local function announceDisconnect()
	pcall(BridgeClientLocal.notifyDisconnect)
	local token = Auth.getSessionToken()
	if not token then return end
	pcall(function()
		HttpService:RequestAsync({
			Url = API_BASE .. "/bridge/disconnect",
			Method = "POST",
			Headers = { ["Content-Type"] = "application/json", ["x-forge-token"] = token },
			Body = "{}",
		})
	end)
end

local function startBridge()
	UI.setState({ forceKeyEntry = false })
	-- Reaching startBridge means the user explicitly connected (Connect
	-- click / Sign-in), so authorize destructive bulk ops for this session.
	desktopPaused = false
	CommandExecutor.setDestructiveConfirmed(true)
	stopTransports()

	local mode = getMode()
	if mode == "cloud" then
		startCloud()
	elseif mode == "desktop" then
		if desktopAvailable() then
			startLocal()
		else
			-- Pinned to desktop, app not running. Do NOT dead-end here: the
			-- user pinned this hours ago, quit the app, and is now staring at
			-- "Desktop app not running" with no way forward except discovering
			-- the pill again (founder, 2026-07-29). Fall back to the cloud and
			-- SAY that is what happened; the pin still applies the moment the
			-- app comes back (the watcher re-attaches).
			print("[picoo] pinned to desktop but the app is not running — using the website instead")
			startCloud()
			UI.setState({ statusOverride = "Desktop app not running — using the website" })
		end
	else
		if desktopAvailable() then startLocal() else startCloud() end
	end

	startDesktopWatcher()
	task.delay(0.5, runPermissionProbe)
end

-- UI can force a transport; re-connect immediately so the change is visible.
UI.onModeChanged(function(mode)
	plugin:SetSetting(MODE_SETTING, mode)
	if Auth.getApiKey() ~= "" then startBridge() end
end)

UI.onPermissionRetryClicked(runPermissionProbe)

-- candidateKey: a key the user just typed that we have NOT saved yet. See the
-- persist-after-verify note in UI.onSaveKeyClicked below.
function tryAuth(reason, candidateKey)  -- assigns the forward-declared local above
	local key = candidateKey or Auth.getApiKey()
	if key == "" then
		-- No API key — but if the desktop app is running it already stamped a
		-- pairing token into this file, and that is enough for local mode.
		-- Without this branch a fresh desktop user would be asked for a key
		-- they never needed, which is the whole point of the app.
		if getMode() ~= "cloud" and EMBEDDED_TOKEN:sub(1, 6) == "picoo-" and desktopAvailable() then
			CommandExecutor.setDestructiveConfirmed(true)
			startLocal()
			startDesktopWatcher()
			task.delay(0.5, runPermissionProbe)
			return true
		end
		UI.setState({ hasApiKey = false, connected = false })
		return false
	end

	-- forceKeyEntry=false so the panel shows "Connecting…" and not the form we
	-- may be about to put back.
	UI.setState({ hasApiKey = true, connected = false, forceKeyEntry = false, error = nil, statusOverride = "Connecting…" })

	local robloxUid = getRobloxUserId()
	local ok, err, kind = Auth.authenticate(key, robloxUid)
	if ok then
		-- PERSIST ONLY AFTER THE SERVER SAID YES (2026-08-26).
		--
		-- onSaveKeyClicked used to call Auth.setApiKey(key) BEFORE this, so a
		-- key that has never once authenticated was written to plugin settings
		-- and hasApiKey went true — which hides the paste box (UI.lua:115) and
		-- leaves "Connect" as the only button on screen, re-submitting the same
		-- dead key forever. It survived Studio restarts too, because boot reads
		-- the saved key and shows "Ready to connect". There was no way back to
		-- the form except an 18px text button positioned at y=306 in a widget
		-- whose default float height is 320, with the footer sitting on top of
		-- it. Measured: 66 Roblox accounts, 1,679 attempts, zero connections;
		-- the 33 who DID recover took a median of 36 seconds once they could
		-- reach the box.
		Auth.setApiKey(key)
		print("[picoo] Authenticated (" .. (reason or "manual") .. ") — starting bridge poll, session=" .. SESSION_ID:sub(1, 8))
		-- Update check (§2): the server returns latest_plugin_version on the
		-- auth handshake. We control both strings, so a plain ~= is enough.
		-- Store users get native Creator-Store auto-update; manual-paste
		-- users see the emerald banner pointing at the update URL.
		local latest = Auth.getLatestVersion()
		if latest and latest ~= "" and latest ~= SHORT_VERSION then
			print("[picoo] Update available: running " .. SHORT_VERSION .. ", latest " .. latest)
			UI.setState({ updateAvailable = true, updateUrl = Auth.getUpdateUrl() })
		end
		startBridge()
		return true
	else
		warn("[picoo] Auth failed (" .. tostring(kind) .. "): " .. tostring(err))
		BridgeClient.stop()
		-- A CREDENTIAL failure puts the form back, with what was typed still in
		-- it, so the user can fix a bad paste in place. A TRANSPORT failure
		-- (Studio offline, HTTP disabled, our 500) does NOT: the saved key is
		-- probably fine and swapping the Connect button for a sign-in form
		-- would be us guessing, badly, that the user's credential is wrong.
		local credential = (kind == "credential")
		UI.setState({
			connected = false,
			statusOverride = "Sign-in failed",
			error = err,
			forceKeyEntry = credential or nil,
			keyDraft = credential and key or nil,
			-- If nothing was ever saved, do not claim we hold a key.
			hasApiKey = (Auth.getApiKey() ~= ""),
		})
		return false
	end
end

UI.onSaveKeyClicked(function(key)
	-- The key is handed to tryAuth as a CANDIDATE and is written to plugin
	-- settings only if the server accepts it. See tryAuth above for the three
	-- weeks of retrying that this ordering caused.
	tryAuth("save-key", key)
	-- NOTE: we used to auto-open the chat tab here, but Studio's plugin API
	-- but the wiki-page API only opens Roblox's own docs (create.roblox.com/docs)
	-- — it can't open an external URL, so it sent users to the wrong place.
	-- Studio plugins have no API to open an arbitrary browser URL, so we just
	-- rely on the connected-state help text to point users back to the
	-- browser tab they already used to copy their key.
end)

UI.onConnectClicked(function()
	tryAuth("connect-button")
end)

UI.onDisconnectClicked(function()
	-- Stop BOTH transports. Stopping only the cloud client left the loopback
	-- poll running, so the desktop app still showed "Studio connected" after
	-- the user explicitly disconnected (2026-07-29).
	announceDisconnect()   -- BEFORE stopping, while the session is still valid
	stopTransports()
	desktopPaused = true   -- don't let the auto-watcher immediately re-attach
	CommandExecutor.setDestructiveConfirmed(false)
	Auth.clearSession()
	UI.setState({ connected = false, statusOverride = nil, bridgeMode = nil })
	desktopAvailable()   -- refresh the "app is there" hint for the offline UI
	print("[picoo] Disconnected")
end)

-- Reconnect (v1.2.1): the one-tap version of Disconnect → Connect. Fixes the
-- stuck-session case the founder hit where only that two-step dance revived
-- the bridge.
UI.onReloadClicked(function()
	stopTransports()
	desktopPaused = false
	CommandExecutor.setDestructiveConfirmed(false)
	Auth.clearSession()
	UI.setState({ connected = false, statusOverride = "Reconnecting…" })
	print("[picoo] Reconnecting…")
	task.wait(0.3)
	tryAuth("reload-button")
end)

UI.onChangeKeyClicked(function()
	stopTransports()
	UI.setState({ forceKeyEntry = true })
	CommandExecutor.setDestructiveConfirmed(false)
	Auth.clearSession()
	Auth.clearApiKey()
	UI.setState({ hasApiKey = false, connected = false, statusOverride = nil, error = nil, keyDraft = "" })
	print("[picoo] API key cleared — sign in with a new key")
end)

-- ============================================================
-- Toolbar toggle
-- ============================================================
toggleButton.Click:Connect(function()
	widget.Enabled = not widget.Enabled
end)

widget:GetPropertyChangedSignal("Enabled"):Connect(function()
	toggleButton:SetActive(widget.Enabled)
	-- Stop the poll beacon when the widget is closed. Polling is only
	-- meaningful while the user has the Picoo panel open and is building;
	-- a closed widget with a live background HTTPS loop reads as a bot.
	if not widget.Enabled and BridgeClient.isRunning() then
		BridgeClient.stop()
		CommandExecutor.setDestructiveConfirmed(false)
		UI.setState({ connected = false, statusOverride = nil })
		print("[picoo] Widget closed — bridge poll stopped.")
	end
end)

-- ============================================================
-- Boot: if a key is saved, mark the UI as "has key" but DO NOT auto-start
-- polling. A cached key only pre-fills the connected-state UI; the bridge
-- poll (a background HTTPS beacon) starts ONLY on an explicit Connect
-- click (UI.onConnectClicked → tryAuth). A perpetual background beacon on
-- every Studio launch reads as bot/C2 behavior; making it user-initiated
-- keeps the plugin dormant until the developer actually wants to build.
-- ============================================================
do
	local saved = Auth.getApiKey()
	if saved == "" then
		UI.setState({ hasApiKey = false })
	else
		-- Key present → show the "Ready to connect" state, but stay dormant.
		UI.setState({ hasApiKey = true, connected = false })
		print("[picoo] API key present — click Connect to start.")
	end

	-- DESKTOP APP: connect on boot, no click.
	--
	-- The dormant-until-clicked rule above is about the CLOUD poll: a
	-- background HTTPS beacon to picoo.io on every Studio launch reads as
	-- bot/C2 behaviour, so it waits for intent. A loopback socket to an app
	-- the user just launched on this same machine is not that — nothing
	-- leaves the machine, and launching the app IS the intent. Making the
	-- desktop user click "Connect" (or worse, paste a key they never needed)
	-- defeats the entire point of shipping an app.
	if not desktopPaused and getMode() ~= "cloud" and desktopAvailable() then
		CommandExecutor.setDestructiveConfirmed(true)
		startLocal()
		startDesktopWatcher()
		task.delay(0.5, runPermissionProbe)
		print("[picoo] Desktop app detected on boot — connected without a key.")
	end
end

-- ============================================================
-- Runtime error feedback loop (v1.2.0 — 2026-05-25)
-- ============================================================
-- LogService.MessageOut fires for every Output panel entry across all
-- VMs (plugin, server-Play, client-Play). We capture errors + warnings,
-- filter Studio noise, batch + dedup, and POST to /api/forge/bridge/log
-- so the next /chat turn injects them into the AI's RECENT RUNTIME
-- ERRORS context section. AI offers proactive fixes.
--
-- Without this loop the AI is blind to what actually went wrong at
-- runtime — it can only predict errors from reading the source. With
-- it, the AI sees the actual stack trace + line number and fixes
-- the real bug, not the predicted one.
do
	local STUDIO_NOISE = {
		"StudioAccessToApisNotAllowed",
		"Studio access to APIs is not allowed",
		"Cannot write to DataStore from studio",
		"HttpService is not allowed to access",
		"The current identity is 6",
		"Plugin security",
		"Disconnect from 127%.0%.0%.1",
		"%[picoo%]",  -- our own logs
		"Picoo",
		"Chat pcall FAILED",
		"HttpError: Timedout",
		"HttpError: Timeout",
		"Session expired",
		"Reusing cached session",
		"script_permission=",
		"uploadHierarchy failed",
		"Bridge error: Request failed",
		"Stack Begin",
		"Stack End",
	}

	local function isNoise(msg)
		for _, pat in ipairs(STUDIO_NOISE) do
			if msg:find(pat) then return true end
		end
		return false
	end

	-- Plugin-side buffer. Caps at 30 to bound memory if user is in a
	-- long error storm. Sent up + cleared on flush.
	local pending = {}
	local MAX_PENDING = 30
	local lastFlushAt = 0
	local FLUSH_COOLDOWN = 4  -- seconds — don't hammer the endpoint
	local FLUSH_BATCH_TRIGGER = 5  -- flush early if ≥5 entries pile up

	-- Dedup: same exact message within 2s = skip. Studio sometimes
	-- repeats the same error 3-4 times per frame for hung Touched loops.
	local recentSeen = {}  -- [message] = os.time()
	local DEDUP_WINDOW = 2

	local function tryPushLogs()
		if #pending == 0 then return end
		local token = Auth.getSessionToken()
		if not token then return end  -- not connected yet; keep buffering
		local batch = pending
		pending = {}
		lastFlushAt = os.time()
		task.spawn(function()
			local ok, err = pcall(function()
				HttpService:RequestAsync({
					Url = API_BASE .. "/bridge/log",
					Method = "POST",
					Headers = {
						["Content-Type"] = "application/json",
						["x-forge-token"] = token,
					},
					Body = HttpService:JSONEncode({
						session_id = SESSION_ID,
						logs = batch,
					}),
				})
			end)
			if not ok then
				-- Endpoint unreachable — drop the batch rather than re-queue
				-- and risk infinite growth. Next runtime error fires fresh.
				warn("[picoo] runtime log push failed: " .. tostring(err))
			end
		end)
	end

	LogService.MessageOut:Connect(function(message, messageType)
		if messageType ~= Enum.MessageType.MessageWarning
			and messageType ~= Enum.MessageType.MessageError then
			return
		end
		if isNoise(message) then return end

		local now = os.time()
		local key = message:sub(1, 200)
		if recentSeen[key] and (now - recentSeen[key]) < DEDUP_WINDOW then
			return
		end
		recentSeen[key] = now

		-- Trim recentSeen occasionally so it doesn't grow forever.
		if math.random(1, 50) == 1 then
			for k, t in pairs(recentSeen) do
				if (now - t) > 30 then recentSeen[k] = nil end
			end
		end

		-- Try to parse "ScriptName:Line" source hint from stack traces.
		-- Studio formats like "ServerScriptService.X:12: attempt to ..."
		local source = nil
		do
			local src = message:match("^([^:]+):(%d+):")
			if src then source = src end
		end

		table.insert(pending, {
			level = (messageType == Enum.MessageType.MessageError) and "error" or "warning",
			message = message:sub(1, 1500),
			source = source,
		})
		if #pending > MAX_PENDING then
			table.remove(pending, 1)  -- drop oldest
		end

		-- Early flush when batch piles up
		if #pending >= FLUSH_BATCH_TRIGGER then
			tryPushLogs()
		end
	end)

	-- Periodic flush — catches single errors that didn't trigger the
	-- batch threshold. Runs forever once started.
	task.spawn(function()
		while true do
			task.wait(FLUSH_COOLDOWN)
			if #pending > 0 and (os.time() - lastFlushAt) >= FLUSH_COOLDOWN then
				tryPushLogs()
			end
		end
	end)
end

-- Stop the bridge cleanly when the plugin is unloaded (Studio close, reload).
plugin.Unloading:Connect(function()
	-- Studio is closing or reloading the plugin: same goodbye, so the chat
	-- header and the desktop app do not claim a connection that just died.
	pcall(announceDisconnect)
	stopTransports()
end)

print("[picoo] Loaded — " .. PLUGIN_BUILD .. " session=" .. SESSION_ID:sub(1, 8))
