SLOPSHOPPER

roclaude

Roblox Studio safety layer for Claude Code (Studio MCP): RemoteEvent security audit, undo, Team Create protection, a confirmation before destructive Luau, and…

newpanebandrowsguardcommand
v0.5.0MITupdated 2026-10-08vinkdc/roclaude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · roclaude
│ ┃ roclaude-remotes ✕ › fix the failing auth test and add an audit log call │ ┃ Run /roclaude remotes to audit this game's │ ┃ RemoteEvents. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /roclaude │ ⎿ roclaude: roclaude is waiting: Claude has not used the Roblox St │ │ ● Roblox Studio Not connected ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Roblox Studio Not connected
Pane · roclaude-remotes
Run /roclaude remotes to audit this game's RemoteEvents.
Pane · roclaude-activity
╭──────────────────────────────────────────────────────╮ │ IDLE │ │ Nothing touched yet │ ╰──────────────────────────────────────────────────────╯ CLAUDE TOUCHED 0 ITEMS Scripts and objects Claude reads or changes in Studio show up here. Select something Claude touched to see what changed.
README

roclaude

A safety layer for building Roblox games with Claude Code through Roblox Studio's built-in MCP server. Claude edits your open place directly; roclaude makes that safe: it audits your RemoteEvents, keeps undo copies of Claude's edits, protects Team Create collaborators' work, and asks you before destructive Luau runs.

Requires Claude Code 2.1.287 or later and Roblox Studio with the MCP server enabled.

What it does

Studio activity: see and replay every change

/roclaude activity (or Activity above the prompt) shows what Claude did in your place. A card says whether Claude is working or done and sums it up ("Claude edited 2 scripts, changed 1 object"). Below it, everything Claude touched is listed by service with a badge: Read, Edited, Created or Changed; rows in progress light up as reading… or editing….

Click a row to see its change, or step through the changes in order with Prev and Next:

  • Scripts show a red and green diff from before Claude's first edit to after its last.
  • Objects Claude changed with Luau show each property before → after (Anchored false → true, Color rgb(99, 68, 44) → rgb(255, 0, 0)), or Created / Deleted. roclaude reads up to five objects' properties just before and after each execute_luau call; changes made through a local variable aren't seen.

Where the app can place a side pane, the view opens beside the conversation (the first time Claude touches Studio, and on /roclaude activity). Where it can't, /roclaude activity draws the same view as a live card in the chat. When a turn ends with changes, a note in the transcript lists them with Claude's own summary.

/roclaude remotes: audit your whole game

It finds every OnServerEvent and OnServerInvoke handler in the open place and sorts each one:

StatusMeaning
UNCHECKEDUses client values without checking them. Exploiters can send anything.
admin-onlyChecks who is calling first (isAdmin(player), player.UserId ~= ..., group rank).
checkedChecks every client value (typeof, tonumber, ranges, lookups).
no argsTakes nothing from the client.

Handlers inside third-party modules (admin kits like Kohl's Admin or Adonis, Packages) are listed separately, so your own code stands out. The results open in a pane with a button that asks Claude to fix the unchecked handlers.

Team Create

  • No edits against a stale copy. roclaude remembers what Claude last read of each script. If a collaborator changed the script since, Claude's multi_edit is refused, and Claude is told to read the script again and redo its edit on the current version, so it never overwrites their work. Claude's own edits don't count as changes.
  • Who's in the session. Collaborators who join or leave the live editing session (as reported in Studio's Output) show in the band and in /roclaude. Destructive-Luau confirmations name them too.
  • Teammate-safe undo. /roclaude undo refuses when the script changed after Claude's edit; /roclaude undo force overrides.

Undo for Claude's edits

Before each multi_edit, roclaude reads the script and keeps a copy. /roclaude undo puts back the latest one, and a script Claude created is deleted. /roclaude history lists what Claude changed this session. Studio edits made through MCP have no git history, so this is your safety net. Up to 50 copies are kept per session.

A confirmation before destructive Luau

When Claude wants to run execute_luau code that deletes instances (:Destroy, :ClearAllChildren), writes DataStores (SetAsync, UpdateAsync, RemoveAsync), kicks or bans players, overwrites .Source, or loads code by asset id, you're asked first, even if you've allowed the tool. Read-only code runs as usual.

A review of every edit

What Claude writes through multi_edit is checked, and findings go back to Claude so it fixes them in the same turn:

  • RemoteEvent handlers that use client values unchecked. Luau type annotations don't count, because exploiters can send any value.
  • wait/spawn/delay instead of the task library, game.Players instead of GetService, lowercase :connect, Instance.new with a parent argument, and LocalPlayer in server scripts.

After Claude changes a script, it's reminded to playtest, read the Output and fix errors before telling you it's done. Claude also gets these conventions in the multi_edit tool's description, so it sees them exactly when it edits.

The band

A band above the prompt shows the place, the Team Create collaborators, how many edits can be undone, the last playtest result and the last check, for example: ◆ test2 · 👥 alex_dev · 3 edit(s) undoable · playtest: clean · Shop clean.

Install

At the prompt of a Claude Code terminal session:

/plugin install roclaude --marketplace vinkdc/roclaude

Answer y to add the marketplace, then choose a scope.

To connect Claude Code to Studio, turn on Assistant → … → Manage MCP Servers → Enable Studio as MCP server in Roblox Studio, then:

claude mcp add --scope user --transport stdio Roblox_Studio -- cmd.exe /c %LOCALAPPDATA%\Roblox\mcp.bat

On macOS, use /Applications/RobloxStudio.app/Contents/MacOS/StudioMCP as the command. roclaude recognizes any MCP server whose name contains "roclaude" or "studio".

Commands

CommandWhat it does
/roclaudeStudio, Team Create and playtest status, and the last check's findings
/roclaude activityShows what Claude touched and replays each change, in a side pane or as a card in the chat
/roclaude remotesAudits every RemoteEvent and RemoteFunction handler, and opens the results in a pane
/roclaude undoRestores the script from Claude's latest edit; /roclaude undo force restores it even if it changed since
/roclaude historyLists Claude's Studio edits this session
/roclaude hide / showHides or shows the band

Footprint

roclaude only talks to the Roblox Studio MCP server. It reads no files, makes no network requests, runs no processes and makes no model calls.

AccessWhy
tool.call on the Studio server's toolsLets each call run, then: reads script_read results to know what Claude saw; around multi_edit, refuses a stale edit, keeps undo copies and attaches findings; reads the studio name, errors and collaborators from list_roblox_studios and get_console_output results.
tool.check on execute_luauChanges "allow" to "ask" for destructive code. It never allows anything that was going to be refused.
tool.describe on multi_editAppends the Roblox and Team Create notes to the tool's description.
$.mcp.call → script_readSaves a script before and after Claude's multi_edit, and reads handler scripts for /roclaude remotes.
$.mcp.call → list_roblox_studios, script_grepOnly for /roclaude remotes: finds the open studio and the scripts that handle remotes.
$.mcp.call → multi_edit, execute_luauOnly when you run /roclaude undo: puts back the saved source, or deletes a script Claude created.

Undo copies and audit results stay in the session's memory. Claude Code asks you once to allow the Studio tools roclaude calls itself.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 6 files
hooks/register.tsx 1067 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PropMap, RemoteReport, Snapshot, StudioInfo, TouchAction, TouchedItem } from '../types'
5import { firstSentence, lineDiff } from './diff'
6import {
7  leafOf,
8  normalizePath,
9  parseProps,
10  pathsChanged,
11  propChanges,
12  serviceOf,
13  settle,
14  summarize,
15  touch,
16} from './activity'
17import { auditRemotes, formatIssues, lintLuau, scanRemotes } from './lint'
18import {
19  changesPlace,
20  countErrors,
21  dangersIn,
22  GREP_CAP,
23  grepScripts,
24  isVendor,
25  luauLookup,
26  saysPlaying,
27  scriptKey,
28  stillAsSeen,
29  stripLineNumbers,
30  studioId,
31  studioMode,
32  studioName,
33  studioTool,
34  teamFrom,
35  writesScripts,
36} from './studio'
37import type { SeenText } from './studio'
38
39const lastLint = atom({ plugin: 'roclaude', key: 'lastLint' } as const, null)
40const isHidden = atom({ plugin: 'roclaude', key: 'isHidden' } as const, false)
41const studio = atom({ plugin: 'roclaude', key: 'studio' } as const, null)
42const history = atom({ plugin: 'roclaude', key: 'history' } as const, [])
43const remotes = atom({ plugin: 'roclaude', key: 'remotes' } as const, null)
44const team = atom({ plugin: 'roclaude', key: 'team' } as const, [])
45const staleBlocks = atom({ plugin: 'roclaude', key: 'staleBlocks' } as const, 0)
46const touched = atom({ plugin: 'roclaude', key: 'touched' } as const, [])
47const isWorking = atom({ plugin: 'roclaude', key: 'isWorking' } as const, false)
48const turnNo = atom({ plugin: 'roclaude', key: 'turn' } as const, 0)
49const selected = atom({ plugin: 'roclaude', key: 'selected' } as const, null)
50const cardRun = atom({ plugin: 'roclaude', key: 'cardRun' } as const, 0)
51
52const MAX_SNAPSHOTS = 50
53const STUDIO_TOOL = /^mcp__.*(roblox|studio).*__[a-z_]+$/i
54const DEFAULT_SERVER = 'Roblox_Studio'
55const REMOTES_PANE = 'roclaude-remotes'
56const ACTIVITY_PANE = 'roclaude-activity'
57// The Studio tools roclaude calls itself through $.mcp.call.
58const OWN_CALLS = new Set([
59  'list_roblox_studios',
60  'get_studio_state',
61  'inspect_instance',
62  'script_grep',
63  'script_read',
64  'multi_edit',
65  'execute_luau',
66])
67const NO_STUDIO: StudioInfo = { name: null, server: null, id: null, errors: null, mode: null, untested: 0 }
68const POLL_MS = 10_000
69// At most this many objects per execute_luau get a before → after record.
70const MAX_INSPECT = 5
71const TEST_PROMPT =
72  'Playtest the open place: start it with start_stop_play, give it a few seconds, read get_console_output, stop play, then fix any errors you find and tell me what you changed.'
73
74// A studio_id argument, or none when no studio is known yet.
75const withStudio = (sid: string) => (sid === '' ? {} : { studio_id: sid })
76
77const textOf = (content: readonly { type: string; text?: string }[]) =>
78  content.map(block => (block.type === 'text' ? (block.text ?? '') : '')).join('')
79const shortName = (path: string) => path.split('.').pop() ?? path
80
81// Reads a script through the Studio MCP server: its source, null when it
82// does not exist yet, undefined when it could not be read.
83async function readScript(
84  $: EngineInterface,
85  server: string,
86  sid: string,
87  path: string,
88): Promise<string | null | undefined> {
89  try {
90    const target = path.startsWith('game.') ? path : `game.${path}`
91    const res = await $.mcp.call(server, 'script_read', { ...withStudio(sid), target_file: target })
92    const text = textOf(res.content)
93    if (res.isError) return /Script not found/i.test(text) ? null : undefined
94    return stripLineNumbers(text)
95  } catch {
96    return undefined
97  }
98}
99
100async function undo($: EngineInterface, isForced: boolean): Promise<string> {
101  const list = await read($, history)
102  const last = list[list.length - 1]
103  if (last === undefined) return 'Nothing to undo: Claude has not edited a Studio script this session.'
104
105  const { server, studioId: sid, path } = last
106  const current = await readScript($, server, sid, path)
107  if (current === undefined) {
108    return `Could not read ${path} to undo it. Is Studio still open and in Edit mode?`
109  }
110  // In Team Create someone else may have edited the script since: never
111  // overwrite that work unless asked to.
112  if (!isForced && last.after !== null && current !== null && current !== last.after) {
113    return `${path} changed after Claude's edit (by you or a teammate), so undo would discard that work. Run /roclaude undo force to restore Claude's saved copy anyway.`
114  }
115
116  let res
117  if (last.source === null) {
118    if (current === null) {
119      await update($, history, all => all.slice(0, -1))
120      return `${path} is already gone; dropped that step.`
121    }
122    const code = [`local s = ${luauLookup(path)}`, 'if s then s:Destroy() end', 'return s ~= nil'].join('\n')
123    res = await $.mcp.call(server, 'execute_luau', { ...withStudio(sid), datamodel_type: 'Edit', code })
124  } else {
125    if (current === last.source) {
126      await update($, history, all => all.slice(0, -1))
127      return `${path} already matches its saved copy; dropped that step.`
128    }
129    if (current === null) {
130      return `${path} no longer exists, so there is nothing to restore into.`
131    }
132    res = await $.mcp.call(server, 'multi_edit', {
133      ...withStudio(sid),
134      datamodel_type: 'Edit',
135      file_path: path,
136      edits: [{ old_string: current, new_string: last.source }],
137    })
138  }
139
140  if (res.isError) return `Undo of ${path} failed: ${textOf(res.content).slice(0, 300)}`
141  await update($, history, all => all.slice(0, -1))
142  return last.source === null
143    ? `Undid the creation of ${path} (deleted it).`
144    : `Restored ${path} to how it was before Claude's edit.`
145}
146
147type Target = {
148  path: string
149  kind: TouchedItem['kind']
150  action: TouchAction
151  propsBefore?: PropMap | null
152  propsAfter?: PropMap | null
153}
154let isActivityOpened = false
155// What Claude changed in the running turn, for the note when it ends.
156const changedThisTurn: string[] = []
157const CARD_MARK = 'roclaude activity #'
158
159// Shows the activity view: as a pane where the surface places one, or else
160// as a card in the chat (the command's row draws it). Answers the row's text.
161async function showActivity($: EngineInterface): Promise<string> {
162  const isShown = async () => {
163    try {
164      return (await $.ui.panes()).some(pane => pane.id === ACTIVITY_PANE && pane.isPlaced)
165    } catch {
166      return false
167    }
168  }
169  // Open (or bring forward) the pane, but never wait on it for long: a
170  // surface may hold the open until it can seat the pane.
171  const opening = $.ui.open({ id: ACTIVITY_PANE, title: 'Studio activity' }).then(
172    opened => opened.isPlaced,
173    () => false,
174  )
175  const isPlaced = await Promise.race([opening, $.clock.sleep(1_500).then(() => null)])
176  if (isPlaced === true || (isPlaced === null && (await isShown()))) {
177    return 'Studio activity is open beside the conversation.'
178  }
179  const run = (await read($, cardRun)) + 1
180  await update($, cardRun, () => run)
181  return `${CARD_MARK}${run}`
182}
183
184// An object's properties through the Studio MCP server: null when it does
185// not exist, undefined when they could not be read.
186async function inspectProps(
187  $: EngineInterface,
188  server: string,
189  sid: string,
190  path: string,
191): Promise<PropMap | null | undefined> {
192  try {
193    const res = await $.mcp.call(server, 'inspect_instance', { ...withStudio(sid), path })
194    const text = textOf(res.content)
195    if (res.isError) return /not found|no instance|does not exist|could not find/i.test(text) ? null : undefined
196    return parseProps(text)
197  } catch {
198    return undefined
199  }
200}
201
202// A call on these is starting: show them as being read, edited or changed,
203// and open the activity pane the first time Claude touches the place.
204async function begin($: EngineInterface, targets: readonly Target[], as: NonNullable<TouchedItem['activeAs']>) {
205  if (targets.length === 0) return
206  await update($, touched, list =>
207    targets.reduce<TouchedItem[]>((all, target) => {
208      const known = all.find(one => one.path === normalizePath(target.path))
209      return touch(all, { ...target, action: known?.action ?? 'read', isActive: true, activeAs: as })
210    }, [...list]),
211  )
212  if (!isActivityOpened) {
213    isActivityOpened = true
214    // Where no pane can be placed (an app without side panes, a headless
215    // run) this waits unseen; the band's Activity button shows a card instead.
216    $.ui.open({ id: ACTIVITY_PANE, title: 'Studio activity' }).catch(() => undefined)
217  }
218}
219
220// The call ended: record what it did, or only settle the rows when it failed.
221// A change becomes the one the activity view shows.
222async function finish($: EngineInterface, targets: readonly Target[], isDone: boolean) {
223  if (targets.length === 0) return
224  await update($, touched, list =>
225    targets.reduce<TouchedItem[]>(
226      (all, target) =>
227        isDone ? touch(all, { ...target, isActive: false, activeAs: null }) : settle(all, target.path),
228      [...list],
229    ),
230  )
231  const changes = isDone ? targets.filter(target => target.action !== 'read') : []
232  for (const target of changes) {
233    const path = normalizePath(target.path)
234    if (!changedThisTurn.includes(path)) changedThisTurn.push(path)
235  }
236  const last = changes[changes.length - 1]
237  if (last !== undefined) await update($, selected, () => normalizePath(last.path))
238}
239
240// Asks the Studio MCP server which place is open and whether it is playing,
241// for the band. Writes only what changed, so an idle poll redraws nothing.
242async function probeStudio($: EngineInterface): Promise<void> {
243  try {
244    const known = await read($, studio)
245    const server = known?.server ?? DEFAULT_SERVER
246    let { id, name } = known ?? NO_STUDIO
247    if (id === null || name === null) {
248      const text = textOf((await $.mcp.call(server, 'list_roblox_studios', {})).content)
249      id = studioId(text)
250      name = studioName(text)
251      if (id === null) return
252    }
253    const sid = id
254    const state = await $.mcp.call(server, 'get_studio_state', { studio_id: sid })
255    const mode = state.isError ? (known?.mode ?? null) : studioMode(textOf(state.content))
256
257    if (known?.id === sid && known.name === name && known.server === server && known.mode === mode) return
258    await update($, studio, info => noteMode({ ...(info ?? NO_STUDIO), server, id: sid, name }, mode))
259  } catch {
260    // No Studio MCP server, or Studio is closed: the band says not connected.
261  }
262}
263
264// A playtest that starts counts as testing what came before it.
265function noteMode(info: StudioInfo, mode: StudioInfo['mode']): StudioInfo {
266  if (mode === 'play' && info.mode !== 'play') return { ...info, mode, untested: 0, errors: null }
267  return { ...info, mode }
268}
269
270// Audits every RemoteEvent and RemoteFunction handler in the open place.
271async function scanStudio($: EngineInterface): Promise<RemoteReport | string> {
272  const seen = await read($, studio)
273  const server = seen?.server ?? DEFAULT_SERVER
274  let id = seen?.id ?? null
275  try {
276    // A server that just started needs a few seconds before Studio registers.
277    for (let attempt = 0; id === null && attempt < 4; attempt += 1) {
278      if (attempt > 0) await $.clock.sleep(2_000)
279      const listed = await $.mcp.call(server, 'list_roblox_studios', {})
280      id = studioId(textOf(listed.content))
281    }
282    if (id === null) return 'No Roblox Studio is connected. Open your place with the MCP server enabled in Assistant settings.'
283
284    const grep = await $.mcp.call(server, 'script_grep', { studio_id: id, query: 'OnServer' })
285    if (grep.isError) return `Could not search the place's scripts: ${textOf(grep.content).slice(0, 200)}`
286    const found = grepScripts(textOf(grep.content))
287
288    const handlers: RemoteReport['handlers'][number][] = []
289    for (const script of found.scripts) {
290      const source = await readScript($, server, id, script)
291      if (typeof source !== 'string') continue
292      handlers.push(...scanRemotes(source).map(handler => ({ ...handler, script, isVendor: isVendor(script) })))
293    }
294
295    return {
296      scripts: found.scripts.length,
297      handlers,
298      note:
299        found.matches >= GREP_CAP
300          ? `Studio's script search stops at ${GREP_CAP} matches, so some handlers may be missing.`
301          : null,
302    }
303  } catch (error) {
304    return `Could not reach the Roblox Studio MCP server "${server}": ${error instanceof Error ? error.message : String(error)}`
305  }
306}
307
308const STATUS_ORDER = { unchecked: 0, gated: 1, checked: 2, 'no-args': 3 } as const
309
310// The game's own handlers, worst first, and the third-party ones apart.
311function orderHandlers(report: RemoteReport) {
312  const sorted = [...report.handlers].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status])
313  const own = sorted.filter(handler => !handler.isVendor)
314  const vendor = sorted.filter(handler => handler.isVendor)
315  const bad = own.filter(handler => handler.status === 'unchecked').length
316  return { own, vendor, bad }
317}
318
319function remotesText(report: RemoteReport): string {
320  const { own, vendor, bad } = orderHandlers(report)
321  const row = (handler: RemoteReport['handlers'][number]) =>
322    `  [${handler.status}] ${handler.script}:${handler.line} ${handler.remote}${handler.unchecked.length > 0 ? ` uses ${handler.unchecked.join(', ')} unchecked` : ''}`
323  return [
324    `RemoteEvent audit: ${own.length} server handler(s) in the game's own scripts, ${bad} unchecked.`,
325    ...own.map(row),
326    ...(vendor.length === 0
327      ? []
328      : [`Third-party modules (${vendor.length} handler(s); review or update them upstream):`, ...vendor.map(row)]),
329    ...(report.note === null ? [] : [report.note]),
330  ].join('\n')
331}
332
333const CONVENTIONS = [
334  '',
335  'roclaude notes for editing this place:',
336  '- Create and change scripts only with multi_edit (new scripts: className plus a first edit with an empty old_string), and read them with script_read. roclaude refuses execute_luau code that writes script source (.Source =, UpdateSourceAsync, Instance.new of a script), because only multi_edit edits can be undone, reviewed and kept safe in Team Create.',
337  '- Read a script in full with script_read before you edit it. In Team Create collaborators may be editing too: if a script changed since you last read it, roclaude refuses the edit and you must read it again first.',
338  '- Use the task library (task.wait, task.spawn, task.defer, task.delay), never the deprecated wait, spawn or delay. Get services with game:GetService("Name"); use PascalCase methods.',
339  '- The server never trusts what a client sends through a RemoteEvent or RemoteFunction: check types with typeof, ranges, rate and ownership. Luau type annotations are not runtime checks.',
340  '- Wrap DataStore, HttpService and MarketplaceService calls in pcall. Players.LocalPlayer exists only on the client.',
341  '- After changing scripts, playtest (start_stop_play), read get_console_output, stop, and fix errors before saying you are done. The person can undo your edits with /roclaude undo.',
342].join('\n')
343
344const EXECUTE_NOTE = [
345  '',
346  'roclaude: do not use this tool to read or write script source. Read scripts with script_read; create and change them with multi_edit. Code that writes source (.Source =, UpdateSourceAsync, Instance.new of a Script, LocalScript or ModuleScript) is refused.',
347].join('\n')
348
349const SCRIPT_WRITE_REFUSAL =
350  'roclaude: this Luau writes script source. Create or change scripts with multi_edit instead (for a new script: file_path like game.ServerScriptService.Name, className Script, LocalScript or ModuleScript, and a first edit with old_string "" holding the source). multi_edit edits can be undone with /roclaude undo, are reviewed for exploits, and are kept safe for Team Create collaborators.'
351
352// The activity view: what Claude touched, each row clickable, and the
353// selected change below it, a script's diff or an object's properties.
354async function drawActivity($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], width: number) {
355  const { Box, Button, Code, Text } = $.ui.resolve(e)
356  const list = await read($, touched)
357  const working = await read($, isWorking)
358  const snapshots = await read($, history)
359  const { title, detail } = summarize(list)
360  const isBusy = working || list.some(one => one.isActive)
361
362  const color = { read: 'merged', edited: 'claude', created: 'success', changed: 'suggestion' } as const
363  const label = { read: 'Read', edited: 'Edited', created: 'Created', changed: 'Changed' } as const
364  const status = isBusy ? 'WORKING' : list.length > 0 ? 'DONE' : 'IDLE'
365  const statusColor = isBusy ? 'warning' : list.length > 0 ? 'claude' : 'inactive'
366
367  const changes = list.filter(one => one.action !== 'read')
368  const chosen = (await read($, selected)) ?? changes[changes.length - 1]?.path ?? null
369  const current = list.find(one => one.path === chosen) ?? null
370  const at = current === null ? -1 : changes.findIndex(one => one.path === current.path)
371  const pick = (path: string) => update($, selected, () => path)
372
373  const services: string[] = []
374  for (const one of list) if (!services.includes(serviceOf(one.path))) services.push(serviceOf(one.path))
375
376  // The selected item's change.
377  let body = <Text dimColor>Select something Claude touched to see what changed.</Text>
378  if (current !== null && current.kind === 'script') {
379    const edits = snapshots.filter(one => normalizePath(one.path) === current.path && one.after !== null)
380    const first = edits[0]
381    const last = edits[edits.length - 1]
382    if (current.action === 'read' || first === undefined || last === undefined) {
383      body = <Text dimColor>Claude read this script and did not change it.</Text>
384    } else {
385      const diff = lineDiff(first.source, last.after ?? '')
386      body = (
387        <Box flexDirection="column">
388          <Text>
389            <Text color="success">+{diff.added}</Text> <Text color="error">−{diff.removed}</Text>
390            <Text dimColor>
391              {' '}
392              over {edits.length} edit{edits.length === 1 ? '' : 's'}
393            </Text>
394          </Text>
395          <Box borderStyle="round" borderColor="inactive">
396            {diff.unified === '' ? (
397              <Text dimColor>No line changes.</Text>
398            ) : (
399              <Code key={`diff-${current.path}`} source={diff.unified} format="diff" language="lua" path={leafOf(current.path)} />
400            )}
401          </Box>
402        </Box>
403      )
404    }
405  } else if (current !== null) {
406    const { propsBefore: before, propsAfter: after } = current
407    if (current.action === 'read') body = <Text dimColor>Claude looked at this object and did not change it.</Text>
408    else if (before === undefined && after === undefined) {
409      body = <Text dimColor>Claude changed this with Luau; its properties could not be read.</Text>
410    } else if (after === null) body = <Text color="error">Deleted.</Text>
411    else if (before === null || before === undefined) body = <Text color="success">Created by Claude.</Text>
412    else {
413      const rows = after === undefined ? [] : propChanges(before, after)
414      body =
415        rows.length === 0 ? (
416          <Text dimColor>No property changes seen.</Text>
417        ) : (
418          <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
419            {rows.slice(0, 12).map(row => (
420              <Box key={`prop-${row.name}`} justifyContent="space-between" gap={2}>
421                <Text dimColor>{row.name}</Text>
422                <Text wrap="truncate-start">
423                  <Text color="error">{row.before}</Text>
424                  <Text dimColor> → </Text>
425                  <Text color="success">{row.after}</Text>
426                </Text>
427              </Box>
428            ))}
429            {rows.length > 12 && <Text dimColor>…and {rows.length - 12} more</Text>}
430          </Box>
431        )
432    }
433  }
434
435  return (
436    <Box flexDirection="column" width={width}>
437      <Box borderStyle="round" borderColor={isBusy ? 'warning' : 'inactive'} paddingX={1} flexDirection="column">
438        <Text bold color={statusColor}>
439          {status}
440        </Text>
441        <Text bold>{title}</Text>
442        {detail !== '' && <Text dimColor>{detail}</Text>}
443      </Box>
444
445      <Box justifyContent="space-between" marginTop={1}>
446        <Text bold dimColor>
447          CLAUDE TOUCHED
448        </Text>
449        <Text bold dimColor>
450          {list.length} {list.length === 1 ? 'ITEM' : 'ITEMS'}
451        </Text>
452      </Box>
453      {list.length === 0 && <Text dimColor>Scripts and objects Claude reads or changes in Studio show up here.</Text>}
454      {services.map(service => (
455        <Box flexDirection="column" key={`svc-${service}`}>
456          <Text dimColor>{service}</Text>
457          {list
458            .filter(one => serviceOf(one.path) === service)
459            .map(one => {
460              const isChosen = current?.path === one.path
461              const where = normalizePath(one.path).split('.').slice(1, -1).join('.')
462              const badge = one.isActive ? (one.activeAs === 'reading' ? 'merged' : 'claude') : color[one.action]
463              return (
464                <Box
465                  key={`row-${one.path}`}
466                  justifyContent="space-between"
467                  alignItems="center"
468                  gap={1}
469                  paddingLeft={1}
470                  backgroundColor={isChosen ? 'subtle' : undefined}
471                >
472                  <Box gap={1} flexShrink={1} alignItems="center">
473                    <Text bold color={one.kind === 'script' ? 'suggestion' : 'success'}>
474                      {one.kind === 'script' ? 'LUA' : 'OBJ'}
475                    </Text>
476                    <Button key={`pick-${one.path}`} label={leafOf(one.path)} plain onPress={() => pick(one.path)} />
477                    {where !== '' && (
478                      <Text dimColor wrap="truncate-end">
479                        {where}
480                      </Text>
481                    )}
482                  </Box>
483                  <Box flexShrink={0} paddingX={1} backgroundColor={badge}>
484                    <Text bold color="inverseText">
485                      {one.isActive ? `${one.activeAs}…` : label[one.action]}
486                    </Text>
487                  </Box>
488                </Box>
489              )
490            })}
491        </Box>
492      ))}
493
494      {current !== null && (
495        <Box flexDirection="column" marginTop={1}>
496          <Text bold color="claude">
497            {at >= 0 ? `CHANGE ${at + 1} OF ${changes.length}` : 'READ ONLY'}
498          </Text>
499          <Text bold>{leafOf(current.path)}</Text>
500          <Text dimColor>
501            {current.path} · {label[current.action]}
502          </Text>
503        </Box>
504      )}
505      <Box marginTop={current === null ? 1 : 0}>{body}</Box>
506
507      <Box marginTop={1} gap={1}>
508        {changes.length > 1 && (
509          <>
510            <Button key="prev" label="Prev" onPress={() => pick(changes[Math.max(0, at - 1)]!.path)} />
511            <Button
512              key="next"
513              label="Next"
514              variant="primary"
515              onPress={() => pick(changes[Math.min(changes.length - 1, at + 1)]!.path)}
516            />
517          </>
518        )}
519        {snapshots.length > 0 && (
520          <Button
521            key="undo-last"
522            label="Undo last edit"
523            onPress={async () => {
524              $.ui.toast(await undo($, false))
525            }}
526          />
527        )}
528        {list.length > 0 && !isBusy && (
529          <Button
530            key="clear"
531            label="Clear"
532            onPress={async () => {
533              await update($, touched, () => [])
534              await update($, selected, () => null)
535            }}
536          />
537        )}
538      </Box>
539    </Box>
540  )
541}
542
543export const register: Register = on => {
544  let nudgedThisTurn = false
545  // What Claude last read of each script, to catch edits against a stale copy.
546  const seenText = new Map<string, SeenText>()
547
548  on('session.start', async ($, e, next) => {
549    await $.command.register({
550      name: 'roclaude',
551      description: 'Roblox Studio: audit RemoteEvents, undo Claude’s edits, Team Create status',
552      argumentHint: '[activity|remotes|undo|history|show|hide]',
553    })
554    // Find the open place once the MCP server has had a moment to start.
555    $.clock.after(3_000, () => void probeStudio($))
556    $.clock.every(POLL_MS, () => void probeStudio($))
557    return next(e)
558  })
559
560  on('prompt.submit', async ($, e, next) => {
561    nudgedThisTurn = false
562    changedThisTurn.length = 0
563    await update($, isWorking, () => true)
564    await update($, turnNo, n => n + 1)
565    return next(e)
566  }).catch(($, e, next) => next(e))
567
568  on('turn.complete', async ($, e, next) => {
569    await update($, isWorking, () => false)
570    await update($, touched, list => (list.some(one => one.isActive) ? list.map(one => ({ ...one, isActive: false, activeAs: null })) : list))
571
572    // A turn that changed the place: say so in the transcript, with how to look.
573    if (changedThisTurn.length > 0) {
574      const names = changedThisTurn.map(leafOf)
575      const shown = names.slice(0, 4).join(' · ') + (names.length > 4 ? ` +${names.length - 4}` : '')
576      $.session
577        .append({
578          message: {
579            type: 'system',
580            content: [
581              {
582                type: 'text',
583                text: `Studio activity · Claude changed ${names.length} thing${names.length === 1 ? '' : 's'}: ${shown}\nClaude: ${firstSentence(e.answer)}\nPress Activity above the prompt, or type /roclaude activity, to see each change.`,
584              },
585            ],
586          },
587        })
588        .catch(() => undefined)
589    }
590    return next(e)
591  })
592
593  // Claude reads the notes where it needs them: in multi_edit's description.
594  on('tool.describe', { tool: STUDIO_TOOL }, async ($, e, next) => {
595    const described = await next(e)
596    const name = studioTool(e.tool)?.name
597    if (name === 'multi_edit') return { ...described, description: described.description + CONVENTIONS }
598    if (name === 'execute_luau') return { ...described, description: described.description + EXECUTE_NOTE }
599    return described
600  }).catch(($, e, next) => next(e))
601
602  on('tool.call', { tool: STUDIO_TOOL }, async ($, e, next) => {
603    const found = studioTool(e.tool)
604    if (found === null) return next(e)
605    const args = e as unknown as Record<string, unknown>
606    const usedId = typeof args.studio_id === 'string' ? args.studio_id : null
607
608    const known = await read($, studio)
609    if (known === null || known.server !== found.server || (usedId !== null && known.id !== usedId)) {
610      await update($, studio, info => ({ ...(info ?? NO_STUDIO), server: found.server, id: usedId ?? info?.id ?? null }))
611    }
612
613    if (found.name === 'get_studio_state') {
614      const ran = await next(e)
615      const mode = ran.deny === undefined && ran.isError !== true ? studioMode(ran.text ?? '') : null
616      if (mode !== null) await update($, studio, info => noteMode(info ?? NO_STUDIO, mode))
617      return ran
618    }
619
620    if (found.name === 'start_stop_play') {
621      const ran = await next(e)
622      if (ran.deny === undefined && ran.isError !== true) {
623        await update($, studio, info => noteMode(info ?? NO_STUDIO, args.is_start === true ? 'play' : 'edit'))
624      }
625      return ran
626    }
627
628    if (found.name === 'execute_luau') {
629      const code = typeof args.code === 'string' ? args.code : ''
630      const paths = args.datamodel_type === 'Edit' && changesPlace(code) ? pathsChanged(code).slice(0, MAX_INSPECT) : []
631      const sid = usedId ?? known?.id ?? ''
632      await begin(
633        $,
634        paths.map(path => ({ path, kind: 'instance' as const, action: 'changed' as const })),
635        'changing',
636      )
637      // Each object's properties just before and after, for the before → after view.
638      const before = await Promise.all(paths.map(path => inspectProps($, found.server, sid, path)))
639      const ran = await next(e)
640      const isDone = ran.deny === undefined && ran.isError !== true
641      const after = isDone ? await Promise.all(paths.map(path => inspectProps($, found.server, sid, path))) : []
642      await finish(
643        $,
644        paths.map((path, i) => ({
645          path,
646          kind: 'instance' as const,
647          action: before[i] === null && after[i] != null ? ('created' as const) : ('changed' as const),
648          propsBefore: before[i],
649          propsAfter: after[i],
650        })),
651        isDone,
652      )
653      if (ran.isError === true && saysPlaying(ran.text ?? '')) {
654        await update($, studio, info => noteMode(info ?? NO_STUDIO, 'play'))
655      } else if (ran.deny === undefined && ran.isError !== true && args.datamodel_type === 'Edit' && changesPlace(code)) {
656        await update($, studio, info => ({ ...(info ?? NO_STUDIO), untested: (info?.untested ?? 0) + 1 }))
657      }
658      return ran
659    }
660
661    if (found.name === 'list_roblox_studios') {
662      const ran = await next(e)
663      if (ran.deny === undefined && ran.isError !== true) {
664        const name = studioName(ran.text ?? '')
665        const id = studioId(ran.text ?? '')
666        await update($, studio, info => ({ ...(info ?? NO_STUDIO), name, id: info?.id ?? id }))
667      }
668      return ran
669    }
670
671    if (found.name === 'get_console_output') {
672      const ran = await next(e)
673      if (ran.deny === undefined && ran.isError !== true) {
674        const output = ran.text ?? ''
675        await update($, studio, info => ({ ...(info ?? NO_STUDIO), errors: countErrors(output) }))
676        await update($, team, members => teamFrom(output, members))
677      }
678      return ran
679    }
680
681    if (found.name === 'script_read') {
682      const path = typeof args.target_file === 'string' ? args.target_file : ''
683      const reading = path === '' ? [] : [{ path, kind: 'script' as const, action: 'read' as const }]
684      await begin($, reading, 'reading')
685      const ran = await next(e)
686      await finish($, reading, ran.deny === undefined && ran.isError !== true)
687      if (ran.deny === undefined && ran.isError !== true && path !== '') {
688        const isWhole = args.should_read_entire_file !== false
689        const start = isWhole ? 1 : typeof args.start_line_one_indexed === 'number' ? args.start_line_one_indexed : 1
690        seenText.set(scriptKey(path), { start, lines: stripLineNumbers(ran.text ?? '').split('\n'), isWhole })
691      }
692      return ran
693    }
694
695    if (found.name === 'inspect_instance' || found.name === 'insert_asset') {
696      const isInsert = found.name === 'insert_asset'
697      const parent = typeof args.parentPath === 'string' ? args.parentPath : 'Workspace'
698      const target = isInsert
699        ? `${parent}.${typeof args.assetName === 'string' ? args.assetName : 'Asset'}`
700        : typeof args.path === 'string'
701          ? args.path
702          : ''
703      const items =
704        target === '' ? [] : [{ path: target, kind: 'instance' as const, action: isInsert ? ('created' as const) : ('read' as const) }]
705      await begin($, items, isInsert ? 'changing' : 'reading')
706      const ran = await next(e)
707      const ok = ran.deny === undefined && ran.isError !== true
708      const added = ok && isInsert ? await inspectProps($, found.server, usedId ?? known?.id ?? '', target) : undefined
709      await finish($, isInsert ? items.map(item => ({ ...item, propsBefore: null, propsAfter: added })) : items, ok)
710      if (ok && isInsert) await update($, studio, info => ({ ...(info ?? NO_STUDIO), untested: (info?.untested ?? 0) + 1 }))
711      return ran
712    }
713
714    if (found.name !== 'multi_edit') return next(e)
715
716    const path = typeof args.file_path === 'string' ? args.file_path : ''
717    const editing = [{ path, kind: 'script' as const, action: 'edited' as const }]
718    await begin($, editing, 'editing')
719    const sid = usedId ?? known?.id ?? ''
720    const before = await readScript($, found.server, sid, path)
721
722    // Team Create: refuse an edit made against a copy someone has since changed.
723    const seen = seenText.get(scriptKey(path))
724    if (seen !== undefined && typeof before === 'string' && !stillAsSeen(seen, before)) {
725      seenText.delete(scriptKey(path))
726      await update($, staleBlocks, count => count + 1)
727      await finish($, editing, false)
728      return {
729        deny: `roclaude: ${path} changed after you last read it, likely a Team Create collaborator editing it. Read it again with script_read, then redo your edit on the current version so you don't overwrite their work.`,
730      }
731    }
732
733    const ran = await next(e)
734    if (ran.isError === true && saysPlaying(ran.text ?? '')) {
735      await update($, studio, info => noteMode(info ?? NO_STUDIO, 'play'))
736    }
737    const isEdited = ran.deny === undefined && ran.isError !== true
738    await finish($, before === null ? [{ ...editing[0]!, action: 'created' }] : editing, isEdited)
739    if (!isEdited) return ran
740    await update($, studio, info => ({ ...(info ?? NO_STUDIO), untested: (info?.untested ?? 0) + 1 }))
741
742    const after = before !== undefined ? ((await readScript($, found.server, sid, path)) ?? null) : null
743    if (before !== undefined) {
744      const turn = await read($, turnNo)
745      await update($, history, list => {
746        const id = (list[list.length - 1]?.id ?? 0) + 1
747        const snapshot: Snapshot = { id, server: found.server, studioId: sid, path, source: before, after, turn }
748        return [...list, snapshot].slice(-MAX_SNAPSHOTS)
749      })
750    }
751    if (after !== null) {
752      await update($, remotes, report => {
753        if (report === null) return report
754        const key = scriptKey(path)
755        const kept = report.handlers.filter(handler => scriptKey(handler.script) !== key)
756        const fresh = scanRemotes(after).map(handler => ({ ...handler, script: key, isVendor: isVendor(key) }))
757        if (fresh.length === 0 && kept.length === report.handlers.length) return report
758        return { ...report, handlers: [...kept, ...fresh] }
759      })
760    }
761    // Claude knows the script as its own edit left it.
762    if (after !== null) seenText.set(scriptKey(path), { start: 1, lines: after.split('\n'), isWhole: true })
763    else seenText.delete(scriptKey(path))
764
765    const edits = Array.isArray(args.edits) ? (args.edits as { new_string?: unknown }[]) : []
766    const written = edits.map(edit => (typeof edit.new_string === 'string' ? edit.new_string : '')).join('\n')
767    const className = typeof args.className === 'string' ? args.className : ''
768    const lintPath = className === 'Script' ? `${path}.server.luau` : path
769    const issues = [...lintLuau(lintPath, written), ...auditRemotes(written)].map(issue => ({ ...issue, line: 0 }))
770    await update($, lastLint, () => ({ file: path, issues }))
771
772    const context = [...(ran.context ?? [])]
773    if (issues.length > 0) context.push(formatIssues(path, issues).replace(/^ {2}L0 /gm, '  - '))
774    if (!nudgedThisTurn) {
775      nudgedThisTurn = true
776      context.push(
777        'roclaude: you changed a script in the open place. Before you finish, playtest with start_stop_play, read get_console_output, stop play, and fix any errors. The person can undo your edit with /roclaude undo.',
778      )
779    }
780
781    return { ...ran, context }
782  }).catch(($, e, next) => next(e))
783
784  // Ask the person before Luau that deletes, writes player data or loads outside code.
785  on('tool.check', { tool: STUDIO_TOOL }, async ($, e, next) => {
786    const found = studioTool(e.tool)
787    const verdict = await next(e)
788
789    // roclaude's own calls: reads for /roclaude remotes and snapshots, and the
790    // restore of a /roclaude undo the person typed. Auto mode's classifier gives
791    // no verdict on a plugin's call, so the plugin answers for its own; a deny
792    // rule the person wrote in settings still stands.
793    if (next.origin.plugin === 'roclaude' && found !== null && OWN_CALLS.has(found.name)) {
794      if (verdict.decision === 'deny' && verdict.rule !== undefined) return verdict
795      return { decision: 'allow', reason: `roclaude's own ${found.name}` }
796    }
797
798    if (found?.name !== 'execute_luau' || verdict.decision === 'deny') return verdict
799
800    const input = e.input as { code?: unknown } | null
801    const code = typeof input?.code === 'string' ? input.code : ''
802    if (writesScripts(code)) return { decision: 'deny', reason: SCRIPT_WRITE_REFUSAL }
803
804    const dangers = dangersIn(code)
805    if (dangers.length === 0) return verdict
806
807    const members = await read($, team)
808    const shared = members.length > 1 ? ` Team Create: ${members.join(', ')} will see this change.` : ''
809    return {
810      decision: 'ask',
811      reason: `roclaude: this Luau ${dangers.map(danger => danger.reason).join('; ')}. roclaude cannot undo it.${shared}`,
812    }
813  }).catch(($, e, next) => next(e))
814
815  on('command.run', { command: 'roclaude' }, async ($, e) => {
816    const arg = e.args.trim().toLowerCase()
817    if (arg === 'hide' || arg === 'show') {
818      await update($, isHidden, () => arg === 'hide')
819      return { text: `roclaude band ${arg === 'hide' ? 'hidden' : 'shown'}.` }
820    }
821    if (arg === 'undo' || arg === 'undo force') {
822      return { text: await undo($, arg === 'undo force') }
823    }
824    if (arg === 'activity' || arg === 'files' || arg === 'replay') {
825      return { text: await showActivity($) }
826    }
827    if (arg === 'remotes') {
828      const report = await scanStudio($)
829      if (typeof report === 'string') return { text: report }
830      await update($, remotes, () => report)
831      await $.ui.open({ id: REMOTES_PANE, title: 'RemoteEvent audit' })
832      return { text: remotesText(report) }
833    }
834    if (arg === 'history') {
835      const list = await read($, history)
836      if (list.length === 0) return { text: 'No Studio edits by Claude this session.' }
837      return {
838        text: [
839          `Claude's Studio edits this session, newest last (/roclaude undo reverts the newest):`,
840          ...list.map(s => `  ${s.id}. ${s.path}${s.source === null ? ' (created)' : ''}`),
841        ].join('\n'),
842      }
843    }
844
845    const seen = await read($, studio)
846    if (seen === null) {
847      return {
848        text: 'roclaude is waiting: Claude has not used the Roblox Studio MCP server this session. Try /roclaude remotes, or ask Claude to look at your place.',
849      }
850    }
851    const lint = await read($, lastLint)
852    const list = await read($, history)
853    const members = await read($, team)
854    const blocks = await read($, staleBlocks)
855    return {
856      text: [
857        `Studio: ${seen.name ?? 'connected'}, ${list.length} script edit(s) saved for undo`,
858        `Team Create: ${members.length > 0 ? members.join(', ') : 'no one else seen in Output yet'}${blocks > 0 ? `; ${blocks} stale edit(s) refused` : ''}`,
859        `Last playtest output: ${seen.errors === null ? 'not read yet' : `${seen.errors} error line(s)`}`,
860        lint === null
861          ? 'Last Luau check: none yet.'
862          : lint.issues.length === 0
863            ? `Last Luau check: ${lint.file} is clean.`
864            : formatIssues(lint.file, lint.issues).replace(/^ {2}L0 /gm, '  - '),
865      ].join('\n'),
866    }
867  }).catch(($, e, next) => ({
868    // The command answers even when something in it fails, and says what.
869    text: `roclaude: /roclaude ${e.args} failed (${next.error.kind}). Try again; if it keeps failing, restart the session.`,
870  }))
871
872  on('ui.render', { component: 'Pane', requestId: REMOTES_PANE }, async ($, e) => {
873    const { Box, Button, Text } = $.ui.resolve(e)
874    const report = await read($, remotes)
875    if (report === null) return <Text dimColor>Run /roclaude remotes to audit this game's RemoteEvents.</Text>
876
877    const { own, vendor, bad } = orderHandlers(report)
878    const room = Math.max(3, (e.viewport?.rows ?? 24) - 8)
879    const color = { unchecked: 'error', gated: 'warning', checked: 'success', 'no-args': 'inactive' } as const
880    const label = { unchecked: 'UNCHECKED', gated: 'admin-only', checked: 'checked', 'no-args': 'no args' } as const
881    const unchecked = own
882      .filter(handler => handler.status === 'unchecked')
883      .map(handler => `- ${handler.script}:${handler.line} ${handler.remote} (uses ${handler.unchecked.join(', ')})`)
884
885    return (
886      <Box flexDirection="column">
887        <Text>
888          <Text bold>{own.length}</Text> handler(s) in the game's scripts ·{' '}
889          <Text color={bad > 0 ? 'error' : 'success'}>{bad} unchecked</Text>
890          {vendor.length > 0 && <Text dimColor> · {vendor.length} in third-party modules</Text>}
891        </Text>
892        {own.slice(0, room).map(handler => (
893          <Box flexDirection="column">
894            <Text wrap="truncate-start">
895              <Text color={color[handler.status]}>{label[handler.status].padEnd(10)}</Text> {handler.remote}{' '}
896              <Text dimColor>
897                {handler.script}:{handler.line}
898              </Text>
899            </Text>
900            {handler.unchecked.length > 0 && (
901              <Text dimColor>{'           '}trusts: {handler.unchecked.join(', ')}</Text>
902            )}
903          </Box>
904        ))}
905        {own.length > room && <Text dimColor>…and {own.length - room} more (see /roclaude remotes)</Text>}
906        {report.note !== null && <Text dimColor>{report.note}</Text>}
907        {bad > 0 && (
908          <Box>
909            <Button
910              key="fix"
911              label="Ask Claude to fix unchecked"
912              onPress={() =>
913                $.prompt.submit({
914                  text: [
915                    'Fix these RemoteEvent handlers that roclaude found using client values unchecked. For each, add a permission check or validate every client value (typeof, ranges, cooldowns, ownership), without changing behavior for legitimate players:',
916                    ...unchecked,
917                  ].join('\n'),
918                })
919              }
920            />
921          </Box>
922        )}
923      </Box>
924    )
925  })
926
927  on('ui.render', { component: 'Pane', requestId: ACTIVITY_PANE }, async ($, e) =>
928    drawActivity($, e, e.props.bodyColumns ?? e.viewport?.columns ?? 50),
929  )
930
931  // Where no pane can be placed, /roclaude activity draws the view in the chat.
932  // Only the newest card is live; older ones point to it.
933  on('ui.render', { component: 'CommandOutput', props: { command: 'roclaude' } }, async ($, e, next) => {
934    const match = e.props.text.match(/roclaude activity #(\d+)/)
935    if (match === null) return next(e)
936    const { Text } = $.ui.resolve(e)
937    if (Number(match[1]) !== (await read($, cardRun))) return <Text dimColor>Studio activity (a newer card is below).</Text>
938    return drawActivity($, e, Math.min(e.viewport?.columns ?? 80, 90))
939  })
940
941  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
942    const seen = await read($, studio)
943    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
944    const { Box, Button, Text } = $.ui.resolve(e)
945    const width = e.props.bodyColumns ?? e.viewport?.columns ?? 80
946
947    // A filled tag: the band's status words, like a progress label.
948    const pill = (key: string, text: string, color: 'warning' | 'success' | 'error' | 'inactive' | 'merged') => (
949      <Box key={key} backgroundColor={color} paddingX={1}>
950        <Text bold color="inverseText">
951          {text}
952        </Text>
953      </Box>
954    )
955    const hide = <Button key="hide" label="Hide" plain onPress={() => update($, isHidden, () => true)} />
956
957    if (seen === null) {
958      return (
959        <Box width={width} justifyContent="space-between" alignItems="center">
960          <Box gap={1} alignItems="center">
961            <Text color="inactive">●</Text>
962            <Text bold>Roblox Studio</Text>
963            {pill('state', 'Not connected', 'inactive')}
964            <Text dimColor>Open a place with the MCP server on</Text>
965          </Box>
966          {hide}
967        </Box>
968      )
969    }
970
971    const lint = await read($, lastLint)
972    const list = await read($, history)
973    const members = await read($, team)
974    const report = await read($, remotes)
975    const unchecked = report === null ? 0 : orderHandlers(report).bad
976    const issues = lint === null ? 0 : lint.issues.length
977    const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
978
979    // One colour says how the place is doing: red needs action, yellow a look.
980    const errors = seen.untested === 0 ? (seen.errors ?? 0) : 0
981    const health = unchecked > 0 || errors > 0 ? 'error' : seen.untested > 0 || issues > 0 ? 'warning' : 'success'
982
983    const left = [
984      <Text key="dot" color={health}>
985        ●
986      </Text>,
987      <Text key="name" bold>
988        {seen.name ?? 'Roblox Studio'}
989      </Text>,
990    ]
991    if (seen.mode === 'play') left.push(pill('mode', 'Playing', 'merged'))
992    else if (seen.mode === 'edit') left.push(pill('mode', 'Edit', 'inactive'))
993    if (seen.untested > 0) left.push(pill('result', `${seen.untested} untested`, 'warning'))
994    else if (errors > 0) left.push(pill('result', plural(errors, 'error'), 'error'))
995    else if (seen.errors !== null) left.push(pill('result', 'Tested', 'success'))
996    if (unchecked > 0) {
997      left.push(
998        <Text key="remotes" color="error">
999          {plural(unchecked, 'unchecked remote')}
1000        </Text>,
1001      )
1002    }
1003    if (issues > 0) {
1004      left.push(
1005        <Text key="issues" color="warning">
1006          {plural(issues, 'issue')} in {shortName(lint?.file ?? '')}
1007        </Text>,
1008      )
1009    }
1010    if (members.length > 0) {
1011      left.push(
1012        <Text key="team" dimColor>
1013          {members.length <= 2 ? members.join(', ') : plural(members.length, 'collaborator')}
1014        </Text>,
1015      )
1016    }
1017    if (list.length > 0) {
1018      left.push(
1019        <Text key="undoable" dimColor>
1020          {list.length} undoable
1021        </Text>,
1022      )
1023    }
1024
1025    const right = []
1026    if ((await read($, touched)).length > 0) {
1027      // Runs the command, so the view shows in a pane or, where none can be
1028      // placed, as a card in the chat.
1029      right.push(
1030        <Button
1031          key="activity"
1032          label="Activity"
1033          onPress={() => {
1034            void $.command.run({ command: 'roclaude', args: 'activity' }).catch(() => undefined)
1035          }}
1036        />,
1037      )
1038    }
1039    if (seen.untested > 0 && seen.mode !== 'play') {
1040      right.push(<Button key="test" label="Test" variant="primary" onPress={() => $.prompt.submit({ text: TEST_PROMPT })} />)
1041    }
1042    if (list.length > 0) {
1043      right.push(
1044        <Button
1045          key="undo"
1046          label="Undo"
1047          onPress={async () => {
1048            $.ui.toast(await undo($, false))
1049          }}
1050        />,
1051      )
1052    }
1053    right.push(hide)
1054
1055    return (
1056      <Box width={width} justifyContent="space-between" alignItems="center">
1057        <Box gap={1} alignItems="center" flexWrap="wrap" flexShrink={1}>
1058          {left}
1059        </Box>
1060        <Box gap={1} alignItems="center" flexShrink={0}>
1061          {right}
1062        </Box>
1063      </Box>
1064    )
1065  })
1066}
1067
hooks/diff.ts 111 lines
1// Line diffs of a script before and after an edit, for the replay theater.
2
3export type LineDiff = {
4  added: number
5  removed: number
6  /** A unified diff with @@ hunk headers, as a `Code` element draws it under `format: 'diff'`. */
7  unified: string
8}
9
10const CONTEXT = 2
11const MAX_CELLS = 4_000_000
12
13type Op = { kind: ' ' | '-' | '+'; line: string; oldNo: number; newNo: number }
14
15// The edit script between two line lists: a longest common subsequence on
16// what differs once the shared head and tail are set aside.
17function operations(a: readonly string[], b: readonly string[]): Op[] {
18  let head = 0
19  while (head < a.length && head < b.length && a[head] === b[head]) head += 1
20  let tail = 0
21  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1
22
23  const midA = a.slice(head, a.length - tail)
24  const midB = b.slice(head, b.length - tail)
25  const ops: Op[] = []
26  for (let i = 0; i < head; i += 1) ops.push({ kind: ' ', line: a[i] ?? '', oldNo: i + 1, newNo: i + 1 })
27
28  if (midA.length * midB.length > MAX_CELLS) {
29    // Too large to align: the middle reads as replaced.
30    midA.forEach((line, i) => ops.push({ kind: '-', line, oldNo: head + i + 1, newNo: head + 1 }))
31    midB.forEach((line, i) => ops.push({ kind: '+', line, oldNo: head + midA.length + 1, newNo: head + i + 1 }))
32  } else {
33    const n = midA.length
34    const m = midB.length
35    const table: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
36    for (let i = n - 1; i >= 0; i -= 1) {
37      for (let j = m - 1; j >= 0; j -= 1) {
38        table[i]![j] = midA[i] === midB[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
39      }
40    }
41    let i = 0
42    let j = 0
43    while (i < n || j < m) {
44      if (i < n && j < m && midA[i] === midB[j]) {
45        ops.push({ kind: ' ', line: midA[i]!, oldNo: head + i + 1, newNo: head + j + 1 })
46        i += 1
47        j += 1
48      } else if (j < m && (i === n || table[i]![j + 1]! > table[i + 1]![j]!)) {
49        // On a tie the removal goes first, as diffs read.
50        ops.push({ kind: '+', line: midB[j]!, oldNo: head + i + 1, newNo: head + j + 1 })
51        j += 1
52      } else {
53        ops.push({ kind: '-', line: midA[i]!, oldNo: head + i + 1, newNo: head + j + 1 })
54        i += 1
55      }
56    }
57  }
58
59  for (let k = 0; k < tail; k += 1) {
60    const oldNo = a.length - tail + k + 1
61    const newNo = b.length - tail + k + 1
62    ops.push({ kind: ' ', line: a[oldNo - 1] ?? '', oldNo, newNo })
63  }
64  return ops
65}
66
67/** The diff of a script from `before` (null: it did not exist) to `after`. */
68export function lineDiff(before: string | null, after: string): LineDiff {
69  const a = before === null || before === '' ? [] : before.split('\n')
70  const b = after === '' ? [] : after.split('\n')
71  const ops = operations(a, b)
72  const added = ops.filter(op => op.kind === '+').length
73  const removed = ops.filter(op => op.kind === '-').length
74
75  // Hunks: each change with CONTEXT unchanged lines around it, merged when close.
76  const changed = ops.map((op, i) => (op.kind === ' ' ? -1 : i)).filter(i => i !== -1)
77  const hunks: [number, number][] = []
78  for (const i of changed) {
79    const from = Math.max(0, i - CONTEXT)
80    const to = Math.min(ops.length - 1, i + CONTEXT)
81    const last = hunks[hunks.length - 1]
82    if (last !== undefined && from <= last[1] + 1) last[1] = Math.max(last[1], to)
83    else hunks.push([from, to])
84  }
85
86  const lines: string[] = []
87  for (const [from, to] of hunks) {
88    const part = ops.slice(from, to + 1)
89    const oldCount = part.filter(op => op.kind !== '+').length
90    const newCount = part.filter(op => op.kind !== '-').length
91    const first = part[0]!
92    const oldStart = oldCount === 0 ? first.oldNo - 1 : first.oldNo
93    const newStart = newCount === 0 ? first.newNo - 1 : first.newNo
94    lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`)
95    for (const op of part) lines.push(`${op.kind}${op.line}`)
96  }
97  return { added, removed, unified: lines.join('\n') }
98}
99
100/**
101 * The first sentence of Claude's reply that says something, short enough for
102 * a card: a bare "Done." or "All set!" is passed over.
103 */
104export function firstSentence(text: string, max = 160): string {
105  const plain = text.replace(/[`*_#>]/g, '').replace(/\s+/g, ' ').trim()
106  // A sentence ends at . ! or ? followed by a space, so Workspace.House.Door stays whole.
107  const sentences = plain.split(/(?<=[.!?])\s+/)
108  const sentence = (sentences.find(one => one.trim().split(' ').length >= 4) ?? sentences[0] ?? plain).trim()
109  return sentence.length > max ? `${sentence.slice(0, max - 1)}…` : sentence
110}
111
hooks/activity.ts 147 lines
1// What Claude touched in the open place, for the Studio activity pane.
2import type { PropMap, TouchAction, TouchedItem } from '../types'
3
4// Stronger actions win when Claude touches the same thing twice.
5const STRENGTH: Record<TouchAction, number> = { read: 0, changed: 1, edited: 2, created: 3 }
6
7/** A dot path as one display form: no `game.`, `workspace` as `Workspace`. */
8export function normalizePath(path: string): string {
9  return path
10    .trim()
11    .replace(/^game\./, '')
12    .replace(/^workspace(?=\.|$)/, 'Workspace')
13}
14
15/** The service a path lies in: its first segment. */
16export const serviceOf = (path: string) => normalizePath(path).split('.')[0] ?? path
17
18/** The last segment of a path, what a row shows. */
19export const leafOf = (path: string) => normalizePath(path).split('.').pop() ?? path
20
21/**
22 * Adds or updates one touched item: a new path goes to the end, a known one
23 * keeps its place and its strongest action. `isActive` marks a call in flight.
24 */
25export function touch(
26  list: readonly TouchedItem[],
27  item: Omit<TouchedItem, 'seq' | 'path'> & { path: string },
28): TouchedItem[] {
29  const path = normalizePath(item.path)
30  const at = list.findIndex(one => one.path === path)
31  if (at === -1) {
32    const seq = (list[list.length - 1]?.seq ?? 0) + 1
33    return [...list, { ...item, path, seq }]
34  }
35  const old = list[at]!
36  const action = STRENGTH[item.action] >= STRENGTH[old.action] ? item.action : old.action
37  const next = [...list]
38  next[at] = {
39    ...old,
40    action,
41    kind: item.kind,
42    isActive: item.isActive,
43    activeAs: item.activeAs,
44    // The first state Claude saw, and the latest it left.
45    propsBefore: old.propsBefore !== undefined ? old.propsBefore : item.propsBefore,
46    propsAfter: item.propsAfter !== undefined ? item.propsAfter : old.propsAfter,
47  }
48  return next
49}
50
51/** An object's properties from inspect_instance's JSON answer. */
52export function parseProps(text: string): PropMap | undefined {
53  try {
54    const info = JSON.parse(text) as { properties?: Record<string, unknown> }
55    if (info.properties === undefined) return undefined
56    const props: Record<string, string | number | boolean> = {}
57    for (const [name, value] of Object.entries(info.properties)) {
58      if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') props[name] = value
59    }
60    return props
61  } catch {
62    return undefined
63  }
64}
65
66// Properties that change by themselves or say nothing to a person.
67const NOISE = new Set(['UniqueId', 'AssemblyLinearVelocity', 'AssemblyAngularVelocity', 'Capabilities', 'Sandboxed'])
68
69/** The properties that differ between two states of an object, by name. */
70export function propChanges(before: PropMap, after: PropMap): { name: string; before: string; after: string }[] {
71  const names = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(name => !NOISE.has(name)).sort()
72  return names
73    .filter(name => before[name] !== after[name])
74    .map(name => ({ name, before: showValue(name, before[name]), after: showValue(name, after[name]) }))
75}
76
77/** A property value as a person reads it: colours as RGB, enums by name, rounded numbers. */
78export function showValue(name: string, value: string | number | boolean | undefined): string {
79  if (value === undefined) return '(none)'
80  if (typeof value === 'number') return String(Math.round(value * 1000) / 1000)
81  if (typeof value === 'boolean') return String(value)
82  if (/Color/.test(name) && /^-?[\d.]+, -?[\d.]+, -?[\d.]+$/.test(value)) {
83    const rgb = value.split(',').map(part => Math.round(Number(part) * 255))
84    return `rgb(${rgb.join(', ')})`
85  }
86  if (value.startsWith('Enum.')) return value.split('.').pop() ?? value
87  const numbers = value.split(',').map(part => part.trim())
88  if (numbers.length > 1 && numbers.every(part => /^-?[\d.e-]+$/.test(part))) {
89    return numbers.map(part => String(Math.round(Number(part) * 100) / 100)).join(', ')
90  }
91  return value.length > 40 ? `${value.slice(0, 39)}…` : value
92}
93
94/** Marks every item settled, at the end of a call. */
95export const settle = (list: readonly TouchedItem[], path: string) =>
96  list.map(one => (one.path === normalizePath(path) ? { ...one, isActive: false, activeAs: null } : one))
97
98const ROOT = String.raw`(?:game|workspace)(?:\.[A-Za-z_]\w*)+`
99const MUTATING = 'Destroy|ClearAllChildren|SetAttribute|PivotTo|MoveTo|AddTag|RemoveTag|Remove|ScaleTo|SetPrimaryPartCFrame'
100const ASSIGN = new RegExp(`\\b(${ROOT})\\s*=(?!=)`, 'g')
101const CALL = new RegExp(`\\b(${ROOT})\\s*:\\s*(?:${MUTATING})\\s*\\(`, 'g')
102
103/**
104 * The instances Luau changes, as far as its text shows: `workspace.Part.Color = x`
105 * changes Workspace.Part, `workspace.Old:Destroy()` changes Workspace.Old.
106 * Changes made through a local variable are not seen.
107 */
108export function pathsChanged(code: string): string[] {
109  const found: string[] = []
110  const add = (path: string) => {
111    const normal = normalizePath(path)
112    if (normal.includes('.') && !found.includes(normal)) found.push(normal)
113  }
114  for (const match of code.matchAll(ASSIGN)) {
115    const segments = (match[1] ?? '').split('.')
116    add(segments.slice(0, -1).join('.'))
117  }
118  for (const match of code.matchAll(CALL)) add(match[1] ?? '')
119  return found
120}
121
122/** One line saying what Claude did, for the pane's card. */
123export function summarize(list: readonly TouchedItem[]): { title: string; detail: string } {
124  const scripts = (action: TouchAction) => list.filter(one => one.kind === 'script' && one.action === action)
125  const edited = [...scripts('created'), ...scripts('edited')]
126  const changed = list.filter(one => one.kind === 'instance' && (one.action === 'changed' || one.action === 'created'))
127  const read = list.filter(one => one.action === 'read')
128
129  const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
130  const parts: string[] = []
131  if (edited.length > 0) parts.push(`edited ${plural(edited.length, 'script')}`)
132  if (changed.length > 0) parts.push(`changed ${plural(changed.length, 'object')}`)
133  const title =
134    parts.length > 0
135      ? `Claude ${parts.join(', ')}`
136      : read.length > 0
137        ? `Claude read ${plural(read.length, 'item')}`
138        : 'Nothing touched yet'
139
140  const names = [...edited, ...changed].map(one => leafOf(one.path))
141  const shown = names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3}` : '')
142  const detail = [shown, read.length > 0 && parts.length > 0 ? `read ${read.length}` : '']
143    .filter(text => text !== '')
144    .join(' · ')
145  return { title, detail }
146}
147
hooks/lint.ts 251 lines
1import type { LintIssue, RemoteHandler } from '../types'
2
3type Rule = {
4  id: string
5  pattern: RegExp
6  message: string
7  // Match against the line with string literals kept (default: blanked).
8  keepsStrings?: boolean
9  unless?: RegExp
10  when?: (path: string) => boolean
11}
12
13const SERVICES = [
14  'Players',
15  'ReplicatedStorage',
16  'ServerStorage',
17  'ServerScriptService',
18  'RunService',
19  'UserInputService',
20  'TweenService',
21  'DataStoreService',
22  'HttpService',
23  'MarketplaceService',
24  'CollectionService',
25  'SoundService',
26  'StarterGui',
27  'Debris',
28]
29
30// A script that runs on the server: under ServerScriptService or
31// ServerStorage, or named `*.server.luau` (how a new Script is passed in).
32export const isServerScript = (path: string) =>
33  /\.server\.luau?$/i.test(path) || /(^|\.)(ServerScriptService|ServerStorage)\./.test(path)
34
35export const RULES: readonly Rule[] = [
36  {
37    id: 'task-wait',
38    pattern: /(^|[^.:\w])wait\s*\(/,
39    unless: /function\s+wait\b/,
40    message: 'wait() is deprecated and throttled; use task.wait().',
41  },
42  {
43    id: 'task-spawn',
44    pattern: /(^|[^.:\w])spawn\s*\(/,
45    unless: /function\s+spawn\b/,
46    message: 'spawn() is deprecated; use task.spawn() or task.defer().',
47  },
48  {
49    id: 'task-delay',
50    pattern: /(^|[^.:\w])delay\s*\(/,
51    unless: /function\s+delay\b/,
52    message: 'delay() is deprecated; use task.delay().',
53  },
54  {
55    id: 'instance-parent-arg',
56    pattern: /Instance\.new\s*\(\s*(["'])[^"']*\1\s*,/,
57    keepsStrings: true,
58    message: "Instance.new's parent argument is slow; set properties first and .Parent last.",
59  },
60  {
61    id: 'get-service',
62    pattern: new RegExp(`\\bgame\\.(${SERVICES.join('|')})\\b`),
63    message: 'Get services with game:GetService("Name") instead of game.Name.',
64  },
65  {
66    id: 'lowercase-method',
67    pattern: /:(connect|disconnect|wait|findFirstChild|getChildren|remove|clone|destroy|isA)\s*\(/,
68    message: 'Lowercase Roblox methods are deprecated; use the PascalCase name (:Connect, :FindFirstChild, :Destroy, ...).',
69  },
70  {
71    id: 'server-localplayer',
72    pattern: /\bLocalPlayer\b/,
73    when: isServerScript,
74    message: 'Players.LocalPlayer is nil on the server; take the player from the event or Players:GetPlayers().',
75  },
76]
77
78const MAX_ISSUES = 30
79
80// Splits one line into code with strings kept and code with strings blanked,
81// dropping comments. `inBlock` carries a --[[ block comment across lines.
82function scanLine(line: string, inBlock: boolean) {
83  let code = ''
84  let bare = ''
85  let i = 0
86
87  if (inBlock) {
88    const end = line.indexOf(']]')
89    if (end === -1) return { code, bare, inBlock: true }
90    i = end + 2
91  }
92
93  while (i < line.length) {
94    const ch = line[i]
95    if (ch === '"' || ch === "'") {
96      let j = i + 1
97      while (j < line.length && line[j] !== ch) j += line[j] === '\\' ? 2 : 1
98      code += line.slice(i, j + 1)
99      bare += ch + ch
100      i = j + 1
101      continue
102    }
103    if (ch === '-' && line[i + 1] === '-') {
104      if (/^--\[=*\[/.test(line.slice(i))) {
105        const end = line.indexOf(']]', i)
106        if (end === -1) return { code, bare, inBlock: true }
107        i = end + 2
108        continue
109      }
110      break
111    }
112    code += ch
113    bare += ch
114    i += 1
115  }
116
117  return { code, bare, inBlock: false }
118}
119
120/**
121 * Checks Luau source for deprecated or risky Roblox patterns. `firstLine` is
122 * the 1-based line the source starts at in its file (for a snippet).
123 */
124export function lintLuau(path: string, source: string, firstLine = 1): LintIssue[] {
125  const issues: LintIssue[] = []
126  const rules = RULES.filter(rule => rule.when === undefined || rule.when(path))
127  let inBlock = false
128
129  source.split(/\r?\n/).forEach((line, index) => {
130    const scanned = scanLine(line, inBlock)
131    inBlock = scanned.inBlock
132
133    for (const rule of rules) {
134      const text = rule.keepsStrings ? scanned.code : scanned.bare
135      if (rule.pattern.test(text) && !rule.unless?.test(text)) {
136        issues.push({ line: firstLine + index, rule: rule.id, message: rule.message })
137      }
138    }
139  })
140
141  return issues.slice(0, MAX_ISSUES)
142}
143
144const HANDLER =
145  /(OnServerEvent:Connect\s*\(\s*function|OnServerInvoke\s*=\s*function)\s*\(([^)]*)\)/
146const HANDLER_LINES = 200
147const GATE_LINES = 6
148const GATE =
149  /\bif\s+not\s+\w*(admin|owner|allowed|whitelist|permi|authori[sz]|staff|mod)\w*\s*\(\s*\w+|\.UserId\s*[~=]=|:GetRankInGroup\s*\(|:IsInGroup\s*\(/i
150
151// Ways a server handler checks a value it got from the client.
152const checks = (name: string) => {
153  const n = name === '...' ? '\\.\\.\\.' : name
154  return new RegExp(
155    [
156      `typeof\\s*\\(\\s*${n}\\b`,
157      `\\btype\\s*\\(\\s*${n}\\b`,
158      `tonumber\\s*\\(\\s*${n}\\b`,
159      `assert\\s*\\([^)]*\\b${n}\\b`,
160      `\\bif\\s+not\\s+${n}\\b`,
161      `\\b${n}\\s*[~=]=`,
162      `[=~]=\\s*${n}\\b`,
163      `table\\.find\\s*\\([^)]*\\b${n}\\b`,
164      `\\[\\s*${n}\\s*\\]`,
165      `math\\.clamp\\s*\\(\\s*${n}\\b`,
166      `select\\s*\\(\\s*["']#["']`,
167    ].join('|'),
168  )
169}
170
171// The lines of the function that opens on lines[start], found by counting
172// Luau blocks (function/do/then/repeat against end/until) outside strings and
173// comments; at most HANDLER_LINES lines.
174function handlerBody(lines: readonly string[], start: number): string[] {
175  let depth = 0
176  let inBlock = false
177  const body: string[] = []
178  for (let i = start; i < lines.length && body.length < HANDLER_LINES; i += 1) {
179    const scanned = scanLine(lines[i] ?? '', inBlock)
180    inBlock = scanned.inBlock
181    for (const word of scanned.bare.match(/\b(function|do|then|repeat|elseif|end|until)\b/g) ?? []) {
182      depth += word === 'end' || word === 'until' || word === 'elseif' ? -1 : 1
183    }
184    if (i > start) body.push(lines[i] ?? '')
185    if (depth <= 0) break
186  }
187  return body
188}
189
190/**
191 * Every RemoteEvent and RemoteFunction server handler in a script, and how it
192 * treats what the client sends: `unchecked` uses values without checking
193 * them, `gated` first limits who may call it (admins, the owner), `checked`
194 * checks every value, `no-args` takes nothing from the client. Luau type
195 * annotations are not checks: an exploiter can send any value whatever the
196 * annotation says.
197 */
198export function scanRemotes(source: string, firstLine = 1): RemoteHandler[] {
199  const lines = source.split(/\r?\n/)
200  const handlers: RemoteHandler[] = []
201
202  lines.forEach((line, index) => {
203    const match = HANDLER.exec(line)
204    if (match === null) return
205
206    const params = (match[2] ?? '')
207      .split(',')
208      .map(param => (param.split(':')[0] ?? '').trim())
209      .filter(param => param.length > 0)
210      .slice(1)
211    const remote = /(\w+)\s*\.\s*OnServer(?:Event|Invoke)/.exec(line)?.[1] ?? '?'
212    const kind = /OnServerInvoke/.test(match[1] ?? '') ? 'function' : 'event'
213    const bodyLines = handlerBody(lines, index)
214    const body = bodyLines.join('\n')
215    const opening = bodyLines.slice(0, GATE_LINES).join('\n')
216    const unchecked = params.filter(param => !checks(param).test(body))
217    const status =
218      params.length === 0 ? 'no-args' : GATE.test(opening) ? 'gated' : unchecked.length > 0 ? 'unchecked' : 'checked'
219
220    handlers.push({
221      line: firstLine + index,
222      remote,
223      kind,
224      status,
225      unchecked: status === 'unchecked' ? unchecked : [],
226    })
227  })
228
229  return handlers
230}
231
232/** The unchecked handlers of `scanRemotes`, as findings for Claude. */
233export function auditRemotes(source: string, firstLine = 1): LintIssue[] {
234  return scanRemotes(source, firstLine)
235    .filter(handler => handler.status === 'unchecked')
236    .map(handler => ({
237      line: handler.line,
238      rule: 'remote-unchecked',
239      message: `This server handler uses ${handler.unchecked.join(', ')} from the client without checking it. Exploiters can send any value (type annotations are not enforced): check typeof, ranges, cooldowns and ownership before use.`,
240    }))
241}
242
243export function formatIssues(file: string, issues: readonly LintIssue[]): string {
244  const lines = issues.map(issue => `  L${issue.line} ${issue.rule}: ${issue.message}`)
245  return [
246    `roclaude found ${issues.length} Roblox/Luau issue(s) in ${file}:`,
247    ...lines,
248    'Fix these in the code you just wrote unless the legacy behavior is intended.',
249  ].join('\n')
250}
251
hooks/studio.ts 163 lines
1// Pure helpers for the Roblox Studio MCP server's tools.
2
3/** `mcp__Roblox_Studio__multi_edit` → { server: 'Roblox_Studio', name: 'multi_edit' } */
4export function studioTool(tool: string): { server: string; name: string } | null {
5  const match = /^mcp__(.+?)__([a-z_]+)$/.exec(tool)
6  if (match === null || !/roclaude|studio/i.test(match[1] ?? '')) return null
7  return { server: match[1] ?? '', name: match[2] ?? '' }
8}
9
10/** script_read answers `     1→line`; this gives back the source. */
11export function stripLineNumbers(text: string): string {
12  return text
13    .split('\n')
14    .map(line => line.replace(/^\s*\d+→/, ''))
15    .join('\n')
16}
17
18/** Luau that finds an instance by its dot path (`game.ServerScriptService.Main`). */
19export function luauLookup(path: string): string {
20  const parts = path.replace(/^game\./, '').split('.')
21  const steps = parts.map(part => `:FindFirstChild(${JSON.stringify(part)})`).join('')
22  return `game${steps}`
23}
24
25const SCRIPT_WRITE =
26  /\.Source\s*=(?!=)|UpdateSourceAsync|Instance\.new\s*\(\s*["'](Script|LocalScript|ModuleScript)["']/
27
28/**
29 * Whether Luau run in Studio writes or creates script source, which belongs
30 * in multi_edit, where roclaude can keep undo copies, review the code and
31 * protect Team Create collaborators.
32 */
33export const writesScripts = (code: string) => SCRIPT_WRITE.test(code)
34
35const MUTATION =
36  /[\w\])]\.[A-Za-z_]\w*\s*=(?!=)|:(Destroy|ClearAllChildren|Clone|SetAttribute|PivotTo|MoveTo|AddTag|RemoveTag|Remove|ScaleTo|SetPrimaryPartCFrame)\s*\(|Instance\.new\s*\(/
37
38/** Whether Luau run in Studio likely changes the place (sets a property, adds or removes things). */
39export const changesPlace = (code: string) => MUTATION.test(code)
40
41/** Studio's mode from get_studio_state's answer: `Current Studio Mode: Edit`. */
42export function studioMode(text: string): 'edit' | 'play' | null {
43  const mode = /Current Studio Mode:\s*(\w+)/i.exec(text)?.[1]
44  if (mode === undefined) return null
45  return mode.toLowerCase() === 'edit' ? 'edit' : 'play'
46}
47
48/** Whether a tool's error says the place is in a playtest. */
49export const saysPlaying = (text: string) => /not available in Play mode|in Play mode/i.test(text)
50
51export type Danger = { id: string; reason: string }
52
53const DANGERS: readonly (Danger & { pattern: RegExp })[] = [
54  {
55    id: 'destroy',
56    pattern: /:(Destroy|ClearAllChildren)\s*\(|Debris:AddItem\s*\(/,
57    reason: 'deletes instances from the place',
58  },
59  {
60    id: 'datastore-write',
61    pattern: /:(SetAsync|UpdateAsync|RemoveAsync|IncrementAsync)\s*\(/,
62    reason: 'writes to DataStores, which can change real player data',
63  },
64  {
65    id: 'players',
66    pattern: /:(BanAsync|UnbanAsync|Kick)\s*\(/,
67    reason: 'kicks or bans players',
68  },
69  {
70    id: 'remote-code',
71    pattern: /\brequire\s*\(\s*\d{5,}\s*\)|InsertService:LoadAsset/,
72    reason: 'loads code or assets by id from outside the place',
73  },
74]
75
76/** What a piece of Luau run in Studio would do that the person should approve first. */
77export function dangersIn(code: string): Danger[] {
78  return DANGERS.filter(danger => danger.pattern.test(code)).map(({ id, reason }) => ({ id, reason }))
79}
80
81/** Counts error lines in Studio's Output, as get_console_output returns it. */
82export function countErrors(output: string): number {
83  return output
84    .split('\n')
85    .filter(line => /^[\w.]+:\d+: /.test(line) || /^(Error|Script error)/i.test(line)).length
86}
87
88/** The studio name from list_roblox_studios' JSON answer. */
89export function studioName(text: string): string | null {
90  try {
91    const info = JSON.parse(text) as { studios?: { name?: unknown }[] }
92    const name = info.studios?.[0]?.name
93    return typeof name === 'string' ? name.replace(/\s*\(placeId:.*\)$/, '') : null
94  } catch {
95    return null
96  }
97}
98
99export const GREP_CAP = 50
100
101// Third-party code a game embeds: admin kits and package folders.
102const VENDOR =
103  /Kohl'?s Admin|Adonis|HD ?Admin|Building Tools|(^|[\\/.])Cmdr([\\/.]|$)|(^|[\\/.])(Dev)?Packages([\\/.]|$)|_Index/i
104
105/** Whether a script path (dot or file) lies inside third-party code. */
106export const isVendor = (path: string) => VENDOR.test(path)
107
108/** The scripts script_grep found, in order, from its `Path: X | Line: N | ...` lines. */
109export function grepScripts(text: string): { scripts: string[]; matches: number } {
110  const scripts: string[] = []
111  let matches = 0
112  for (const line of text.split('\n')) {
113    const match = /^Path: (.+?) \| Line: \d+ \|/.exec(line)
114    if (match === null) continue
115    matches += 1
116    const path = match[1] ?? ''
117    if (!scripts.includes(path)) scripts.push(path)
118  }
119  return { scripts, matches }
120}
121
122/**
123 * Who is in the Team Create session, from Studio's Output: `X joined live
124 * editing session.` adds X and a line saying X left removes them.
125 */
126export function teamFrom(output: string, before: readonly string[]): string[] {
127  const team = [...before]
128  for (const line of output.split('\n')) {
129    const joined = /^(\S+) joined (the )?live editing session/i.exec(line)
130    const left = /^(\S+) (left|disconnected from|has left) (the )?live editing session/i.exec(line)
131    const name = joined?.[1] ?? left?.[1]
132    if (name === undefined) continue
133    const at = team.indexOf(name)
134    if (joined !== null && at === -1) team.push(name)
135    if (left !== null && at !== -1) team.splice(at, 1)
136  }
137  return team
138}
139
140/** What Claude last read of a script: its lines from `start` (1-based), whole or a range. */
141export type SeenText = { start: number; lines: readonly string[]; isWhole: boolean }
142
143/** Whether a script still reads as Claude last saw it. */
144export function stillAsSeen(seen: SeenText, source: string): boolean {
145  const lines = source.split('\n')
146  if (seen.isWhole && lines.length !== seen.lines.length) return false
147  return seen.lines.every((line, i) => lines[seen.start - 1 + i] === line)
148}
149
150/** A script path as one key, with or without the leading `game.`. */
151export const scriptKey = (path: string) => path.replace(/^game\./, '')
152
153/** The first studio's id from list_roblox_studios' JSON answer. */
154export function studioId(text: string): string | null {
155  try {
156    const info = JSON.parse(text) as { studios?: { id?: unknown }[] }
157    const id = info.studios?.[0]?.id
158    return typeof id === 'string' ? id : null
159  } catch {
160    return null
161  }
162}
163
types/index.d.ts 93 lines
1export type LintIssue = { line: number; rule: string; message: string }
2export type LintReport = { file: string; issues: readonly LintIssue[] }
3
4/**
5 * A script as it was before Claude's multi_edit (`source`, null when the edit
6 * created it) and right after (`after`, null when it could not be read).
7 */
8export type Snapshot = {
9  id: number
10  server: string
11  studioId: string
12  path: string
13  source: string | null
14  after: string | null
15  /** The turn the edit was made in, for replaying one turn. */
16  turn: number
17}
18
19/**
20 * The Studio MCP server last used and the place's state: its mode, how many
21 * changes Claude made since the last playtest, and that playtest's errors.
22 */
23export type StudioInfo = {
24  name: string | null
25  server: string | null
26  id: string | null
27  errors: number | null
28  mode: 'edit' | 'play' | null
29  untested: number
30}
31
32export type RemoteStatus = 'unchecked' | 'gated' | 'checked' | 'no-args'
33export type RemoteHandler = {
34  line: number
35  remote: string
36  kind: 'event' | 'function'
37  status: RemoteStatus
38  unchecked: readonly string[]
39}
40export type RemoteReport = {
41  scripts: number
42  /** `isVendor`: inside a third-party module (an admin kit, a package), reported apart. */
43  handlers: readonly (RemoteHandler & { script: string; isVendor: boolean })[]
44  note: string | null
45}
46
47export type TouchAction = 'read' | 'edited' | 'created' | 'changed'
48/** An object's properties as Studio's inspect_instance reports them. */
49export type PropMap = Readonly<Record<string, string | number | boolean>>
50/** Something in the place Claude read or changed this session. */
51export type TouchedItem = {
52  path: string
53  kind: 'script' | 'instance'
54  action: TouchAction
55  /** A call on it is in flight, and as what. */
56  isActive: boolean
57  activeAs: 'reading' | 'editing' | 'changing' | null
58  seq: number
59  /**
60   * An object's properties before Claude first changed it this session (null:
61   * it did not exist) and after its latest change (null: it was deleted);
62   * absent when they were not captured.
63   */
64  propsBefore?: PropMap | null
65  propsAfter?: PropMap | null
66}
67
68declare module 'claude-code' {
69  interface PluginState {
70    'roclaude': {
71      lastLint: LintReport | null
72      isHidden: boolean
73      studio: StudioInfo | null
74      history: readonly Snapshot[]
75      remotes: RemoteReport | null
76      /** Who Studio's Output says is in the Team Create session. */
77      team: readonly string[]
78      /** Edits refused because the script changed after Claude read it. */
79      staleBlocks: number
80      /** What Claude read and changed this session, for the activity pane. */
81      touched: readonly TouchedItem[]
82      /** Whether Claude's turn is running. */
83      isWorking: boolean
84      /** Counts the person's prompts, so edits can be grouped by turn. */
85      turn: number
86      /** The touched item whose change the activity view shows. */
87      selected: string | null
88      /** Counts activity cards drawn in the chat; only the newest is live. */
89      cardRun: number
90    }
91  }
92}
93