@el4cteo/rbx-studio-mcp 0.8.3 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +24 -6
  2. package/dist/bridge/rpc.js +1 -1
  3. package/dist/bridge/rpc.js.map +1 -1
  4. package/dist/index.js +7 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +75 -0
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/errors.js +5 -4
  9. package/dist/lib/errors.js.map +1 -1
  10. package/dist/lib/format.js +92 -25
  11. package/dist/lib/format.js.map +1 -1
  12. package/dist/lib/notices.js +29 -0
  13. package/dist/lib/notices.js.map +1 -0
  14. package/dist/lib/protocol.js.map +1 -1
  15. package/dist/lib/sync.js +1141 -0
  16. package/dist/lib/sync.js.map +1 -0
  17. package/dist/lib/syncplan.js +338 -0
  18. package/dist/lib/syncplan.js.map +1 -0
  19. package/dist/lib/tool.js +9 -2
  20. package/dist/lib/tool.js.map +1 -1
  21. package/dist/tools/discover.js +6 -1
  22. package/dist/tools/discover.js.map +1 -1
  23. package/dist/tools/exec.js +5 -0
  24. package/dist/tools/exec.js.map +1 -1
  25. package/dist/tools/instances.js +2 -2
  26. package/dist/tools/instances.js.map +1 -1
  27. package/dist/tools/perf.js +28 -3
  28. package/dist/tools/perf.js.map +1 -1
  29. package/dist/tools/screenshot.js +7 -3
  30. package/dist/tools/screenshot.js.map +1 -1
  31. package/dist/tools/scripts.js +110 -28
  32. package/dist/tools/scripts.js.map +1 -1
  33. package/dist/tools/sync.js +169 -0
  34. package/dist/tools/sync.js.map +1 -0
  35. package/package.json +4 -4
  36. package/plugin/src/Commands.luau +72 -5
  37. package/plugin/src/Config.luau +65 -65
  38. package/plugin/src/Console.luau +5 -0
  39. package/plugin/src/Dispatch.luau +131 -90
  40. package/plugin/src/ExecRuntime.luau +190 -168
  41. package/plugin/src/LogBuffer.luau +38 -9
  42. package/plugin/src/Paths.luau +42 -0
  43. package/plugin/src/Phrase.luau +41 -0
  44. package/plugin/src/Prompt.luau +23 -3
  45. package/plugin/src/ScriptEdit.luau +94 -8
  46. package/plugin/src/Serialize.luau +16 -1
  47. package/plugin/src/Transport.luau +5 -2
  48. package/plugin/src/Undo.luau +74 -10
  49. package/plugin/src/handlers/Capture.luau +818 -809
  50. package/plugin/src/handlers/Debug.luau +19 -16
  51. package/plugin/src/handlers/Discover.luau +31 -1
  52. package/plugin/src/handlers/Perf.luau +100 -21
  53. package/plugin/src/handlers/Scripts.luau +274 -90
  54. package/plugin/src/handlers/Sync.luau +968 -0
  55. package/plugin/src/init.server.luau +47 -2
  56. package/scripts/build.mjs +14 -0
  57. package/scripts/sync-fake.mjs +191 -0
  58. package/scripts/test-live-sync-scale.mjs +150 -0
  59. package/scripts/test-live-sync.mjs +232 -0
  60. package/scripts/test-live-tools.mjs +6 -1
  61. package/scripts/test-plugin.mjs +17 -0
  62. package/scripts/test-results.mjs +41 -0
  63. package/scripts/test-sync-more.mjs +228 -0
  64. package/scripts/test-sync.mjs +245 -0
  65. package/dist/tools/spatial.js +0 -135
  66. package/dist/tools/spatial.js.map +0 -1
  67. package/dist/tools/upload.js +0 -294
  68. package/dist/tools/upload.js.map +0 -1
@@ -9,9 +9,10 @@
9
9
  saved. Reads use the editor buffer for the same reason: handing an agent stale
10
10
  source makes it "fix" changes the user just made.
11
11
 
12
- Edits are also wrapped in a single `Undo` recording, so a batch across ten
13
- scripts is one Ctrl+Z, and a batch that fails half way is rolled back rather
14
- than left half applied.
12
+ Source undo is the script editor's own, per script -- a ChangeHistoryService
13
+ recording cannot capture an editor write. What a batch gets instead is
14
+ all-or-nothing application (see `edit`) and the shared mutation lock, so two
15
+ batches never interleave their writes.
15
16
 
16
17
  The text manipulation itself lives in `TextEdit`, which has no Roblox
17
18
  dependencies and is unit tested.
@@ -56,43 +57,6 @@ local function resolveScript(path: string): LuaSourceContainer
56
57
  return instance :: LuaSourceContainer
57
58
  end
58
59
 
59
- --[[
60
- A short fingerprint of a script's source, for detecting that it moved.
61
-
62
- Handed out by `read` and passed back to `edit`, which refuses to write when
63
- the live source no longer matches. That is the only thing standing between
64
- two agents on one place and a silent overwrite: a line-range edit computed
65
- against source somebody has since changed still applies cleanly, it just
66
- applies to the wrong lines, and nothing anywhere reports it.
67
-
68
- FNV-1a over the whole string, with the length appended. Not a security
69
- hash and does not need to be -- it is guarding against ordinary concurrent
70
- editing, not against someone constructing a collision. The length is there
71
- because it is free and rules out the whole class of same-length accidents.
72
-
73
- The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
74
- a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
75
- so the low bits -- the ones that carry the mixing -- would be quietly
76
- rounded away.
77
- ]]
78
- local function fingerprint(source: string): string
79
- local hash = 2166136261
80
- local length = #source
81
- local index = 1
82
- while index <= length do
83
- local last = math.min(index + 511, length)
84
- local chunk = { string.byte(source, index, last) }
85
- for _, byte in chunk do
86
- hash = bit32.bxor(hash, byte)
87
- local low = bit32.band(hash, 0xFFFF)
88
- local high = bit32.rshift(hash, 16)
89
- -- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
90
- hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
91
- end
92
- index = last + 1
93
- end
94
- return string.format("%08x-%x", hash, length)
95
- end
96
60
 
97
61
  --[[
98
62
  Whether a script is really the source of truth, or a copy of a file on disk.
@@ -197,7 +161,7 @@ function Scripts.read(params: { [string]: any }): { [string]: any }
197
161
  -- Of the whole file, never of the window: `edit` compares it against
198
162
  -- the live source, and a fingerprint of forty lines out of four
199
163
  -- hundred would call an edit safe that it is not.
200
- revision = fingerprint(whole),
164
+ revision = ScriptEdit.revisionOf(target, whole),
201
165
  -- Absent unless the script is bound to a file on disk, in which case
202
166
  -- editing it here is a race against whatever writes that file.
203
167
  fileSync = syncedFile(target),
@@ -222,8 +186,14 @@ end
222
186
  Phase two writes the finished text. If a write fails there -- realistically
223
187
  only a locked or package-owned script -- the scripts already written are
224
188
  restored from the source captured in phase one.
189
+
190
+ Phase one reads, phase two writes, and the editor yields in between, so the
191
+ user can type into a script after it was read. Each write therefore checks,
192
+ inside the editor's own callback, that the text is still what phase one
193
+ transformed, and a restore only puts back text that is still ours. Anything
194
+ newer is kept and named, rather than silently overwritten on either path.
225
195
  ]]
226
- function Scripts.edit(params: { [string]: any }): { [string]: any }
196
+ local function editBatch(params: { [string]: any }): { [string]: any }
227
197
  local edits = params.edits
228
198
  if typeof(edits) ~= "table" or #edits == 0 then
229
199
  Dispatch.fail(
@@ -272,7 +242,7 @@ function Scripts.edit(params: { [string]: any }): { [string]: any }
272
242
  if typeof(stated) ~= "string" or stated == "" then
273
243
  continue
274
244
  end
275
- live = live or fingerprint(before)
245
+ live = live or ScriptEdit.revisionOf(target, before)
276
246
  if stated ~= live then
277
247
  Dispatch.fail(
278
248
  "STALE_SCRIPT",
@@ -293,35 +263,79 @@ function Scripts.edit(params: { [string]: any }): { [string]: any }
293
263
  table.insert(pending, { target = target, before = before, after = after })
294
264
  end
295
265
 
296
- -- Phase two: write. `written` is the compensation log for a mid-batch failure.
266
+ -- Phase two: write. `written` is the compensation log for a mid-batch
267
+ -- failure, and holds every script whose write reached the editor -- a write
268
+ -- whose confirmation failed afterwards has still changed the script.
297
269
  local written: { Pending } = {}
298
270
  local memo: Paths.NameIndex = {}
299
271
  local results: { { [string]: any } } = {}
300
272
 
301
273
  for _, entry in pending do
274
+ local committed = false
302
275
  -- Wrapped in a closure rather than passed to pcall directly: `write`
303
276
  -- returns nothing, and pcall's typed signature expects a value back.
304
277
  local ok, err = pcall(function()
305
- ScriptEdit.write(entry.target, function()
278
+ ScriptEdit.write(entry.target, function(current)
279
+ if current ~= entry.before then
280
+ Dispatch.fail(
281
+ "STALE_SCRIPT",
282
+ string.format("%s changed while this batch was being written.", entry.target:GetFullName()),
283
+ "Somebody typed into it after it was read. Read it again with "
284
+ .. "script_read and rebuild the edit against what is there now."
285
+ )
286
+ end
287
+ committed = entry.after ~= entry.before
306
288
  return entry.after
307
289
  end)
308
290
  end)
291
+ if committed then
292
+ table.insert(written, entry)
293
+ end
309
294
 
310
295
  if not ok then
296
+ local kept: { string } = {}
311
297
  for index = #written, 1, -1 do
312
298
  local done = written[index]
313
- -- Best effort: a restore that fails leaves that script edited, and
314
- -- the original error still describes what actually went wrong.
315
- pcall(function()
316
- ScriptEdit.write(done.target, function()
317
- return done.before
299
+ -- Nothing to undo when the editor never took the write -- and a
300
+ -- write it refused (a locked script) would only be refused again.
301
+ if ScriptEdit.read(done.target) == done.before then
302
+ continue
303
+ end
304
+ local restored, restoreError = pcall(function()
305
+ ScriptEdit.write(done.target, function(current)
306
+ if current == done.after then
307
+ return done.before
308
+ end
309
+ if current ~= done.before then
310
+ Dispatch.fail("ROLLBACK_CONFLICT", "edited again since this batch wrote it; that text was kept")
311
+ end
312
+ return current
318
313
  end)
319
314
  end)
315
+ if not restored then
316
+ local reason = if typeof(restoreError) == "table" then restoreError.message else tostring(restoreError)
317
+ table.insert(kept, string.format("%s (%s)", done.target:GetFullName(), tostring(reason)))
318
+ end
319
+ end
320
+ --[[
321
+ Said outright when the rollback could not finish.
322
+
323
+ The batch promises all-or-nothing, and a restore that fails leaves
324
+ part of it applied. Reporting only the original error would let the
325
+ agent believe nothing changed and retry on top of a half-applied edit.
326
+ ]]
327
+ if #kept > 0 then
328
+ local reason = if typeof(err) == "table" then err.message else tostring(err)
329
+ Dispatch.fail(
330
+ "PARTIAL_EDIT",
331
+ string.format("%s The batch could not be fully rolled back.", tostring(reason)),
332
+ "Still edited, left as they are: " .. table.concat(kept, "; ")
333
+ .. ". Read these scripts before retrying."
334
+ )
320
335
  end
321
336
  error(err, 0)
322
337
  end
323
338
 
324
- table.insert(written, entry)
325
339
  table.insert(results, {
326
340
  path = Paths.of(entry.target, memo),
327
341
  className = entry.target.ClassName,
@@ -331,46 +345,101 @@ function Scripts.edit(params: { [string]: any }): { [string]: any }
331
345
  -- Of what is there now, not of what was asked for: the editor can
332
346
  -- normalise the text it is handed, and a rev that does not match the
333
347
  -- live buffer would refuse the very next edit as stale.
334
- rev = fingerprint(ScriptEdit.read(entry.target)),
348
+ rev = ScriptEdit.revisionOf(entry.target),
335
349
  })
336
350
  end
337
351
 
338
352
  return { items = results }
339
353
  end
340
354
 
355
+ function Scripts.edit(params: { [string]: any }): { [string]: any }
356
+ return Undo.exclusive(function()
357
+ return editBatch(params)
358
+ end)
359
+ end
360
+
341
361
  --[[
342
362
  Searches script source. Matches come from the editor buffer, so text the user
343
363
  has typed but not saved is found too -- which is the state an agent about to
344
364
  edit the file actually needs to see.
365
+
366
+ `patterns` takes up to 16 literal needles searched in one pass. Looking for
367
+ every caller of three functions used to be three calls, each reading every
368
+ script's buffer again; the reads are the cost, not the matching.
369
+
370
+ `mode` picks what comes back: matching lines (the default), one row per
371
+ matching script, or only per-needle counts of matching lines -- the cheap
372
+ way to ask "how widespread is this" before deciding to read anything.
345
373
  ]]
374
+ local GREP_MAX_NEEDLES = 16
375
+ -- Characters of rows handed back, estimated per row. The server clips to its
376
+ -- own response limit as well; this keeps a search over long minified lines from
377
+ -- building a reply far past it only for most of it to be thrown away.
378
+ local GREP_BUDGET = 18_000
379
+ local GREP_LONG_LINE = 256
380
+
346
381
  function Scripts.grep(params: { [string]: any }): { [string]: any }
347
- local pattern = params.pattern
348
- if typeof(pattern) ~= "string" or pattern == "" then
349
- Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern`.")
382
+ local needles: { string } = {}
383
+ local literal = params.literal == true
384
+ local patterns = params.patterns
385
+ if patterns ~= nil then
386
+ if params.pattern ~= nil or typeof(patterns) ~= "table" or #patterns == 0 or #patterns > GREP_MAX_NEEDLES then
387
+ Dispatch.fail(
388
+ "BAD_PARAMS",
389
+ string.format("Pass either `pattern` or 1-%d `patterns`, not both.", GREP_MAX_NEEDLES)
390
+ )
391
+ end
392
+ -- Always literal: the point is identifiers, and mixing Lua patterns into a
393
+ -- batch makes one bad escape fail every needle with it.
394
+ literal = true
395
+ for _, needle in patterns do
396
+ if typeof(needle) ~= "string" or needle == "" then
397
+ Dispatch.fail("BAD_PARAMS", "Every entry in `patterns` must be a non-empty string.")
398
+ end
399
+ table.insert(needles, needle)
400
+ end
401
+ elseif typeof(params.pattern) == "string" and params.pattern ~= "" then
402
+ table.insert(needles, params.pattern)
403
+ else
404
+ Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern` or `patterns`.")
405
+ end
406
+
407
+ local mode = params.mode or "lines"
408
+ if mode ~= "lines" and mode ~= "files" and mode ~= "counts" then
409
+ Dispatch.fail("BAD_PARAMS", string.format("%q is not a grep mode.", tostring(mode)), "Use lines, files or counts.")
350
410
  end
351
411
 
352
412
  local root = if params.path then Paths.resolve(params.path) else game
353
- local literal = params.literal == true
354
413
  local ignoreCase = params.ignoreCase == true
355
414
  local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
356
- local limit = math.min(tonumber(params.limit) or 100, MAX_MATCHES)
357
- local offset = tonumber(params.offset) or 0
415
+ local limit = math.clamp(tonumber(params.limit) or 100, 1, MAX_MATCHES)
416
+ local offset = math.max(0, tonumber(params.offset) or 0)
358
417
  local classFilter = params.className
359
418
 
360
- local needle = if ignoreCase then string.lower(pattern) else pattern
419
+ if ignoreCase then
420
+ for index, needle in needles do
421
+ needles[index] = string.lower(needle)
422
+ end
423
+ end
361
424
 
362
425
  local targets: { LuaSourceContainer } = {}
363
- for _, instance in root:GetDescendants() do
426
+ local function consider(instance: Instance)
364
427
  if not instance:IsA("LuaSourceContainer") then
365
- continue
428
+ return
366
429
  end
367
430
  if root == game and Scope.isNoisy(instance) then
368
- continue
431
+ return
369
432
  end
370
433
  if classFilter and not instance:IsA(classFilter) then
371
- continue
434
+ return
372
435
  end
373
- table.insert(targets, instance)
436
+ table.insert(targets, instance :: LuaSourceContainer)
437
+ end
438
+ -- The root itself counts: `path` naming one script searches that script,
439
+ -- where it used to search only its (usually absent) children.
440
+ consider(root)
441
+ for _, instance in root:GetDescendants() do
442
+ consider(instance)
374
443
  end
375
444
 
376
445
  if #targets > MAX_SCRIPTS then
@@ -381,15 +450,51 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
381
450
  )
382
451
  end
383
452
 
384
- local function badPattern(reason: any): never
453
+ --[[
454
+ A fixed order, so `offset` means the same thing on every page.
455
+
456
+ `GetDescendants` does not return one (see `stableOrder` in Discover): page
457
+ two came from a different ordering than page one, repeating some matches
458
+ and silently skipping others. By full name, with the debug id separating
459
+ same-named scripts.
460
+ ]]
461
+ local keys: { [Instance]: string } = {}
462
+ for _, target in targets do
463
+ local ok, id = pcall(function()
464
+ return target:GetDebugId()
465
+ end)
466
+ keys[target] = target:GetFullName() .. "\0" .. (if ok then id else "")
467
+ end
468
+ table.sort(targets, function(a: Instance, b: Instance): boolean
469
+ return keys[a] < keys[b]
470
+ end)
471
+
472
+ local function badPattern(needle: string, reason: any): never
385
473
  Dispatch.fail(
386
474
  "BAD_PATTERN",
387
- string.format("%s is not a valid Lua pattern: %s", pattern, tostring(reason)),
475
+ string.format("%s is not a valid Lua pattern: %s", needle, tostring(reason)),
388
476
  "Lua patterns escape with %, not backslash, and have no alternation. "
389
477
  .. "Set `literal` to search for the text exactly as written."
390
478
  )
391
479
  end
392
480
 
481
+ -- Which needles occur on `text`, or nil. An invalid Lua pattern raises rather
482
+ -- than simply not matching, so it is reported as a pattern problem.
483
+ local function hits(text: string): { number }?
484
+ local found: { number }? = nil
485
+ for index, needle in needles do
486
+ local ok, from = pcall(string.find, text, needle, 1, literal)
487
+ if not ok then
488
+ badPattern(needle, from)
489
+ end
490
+ if from then
491
+ found = found or {}
492
+ table.insert(found :: { number }, index)
493
+ end
494
+ end
495
+ return found
496
+ end
497
+
393
498
  --[[
394
499
  One search over the whole file before splitting it into lines.
395
500
 
@@ -401,53 +506,80 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
401
506
  ]]
402
507
  -- Any trailing `$` counts, escaped or not: skipping the shortcut costs speed,
403
508
  -- taking it wrongly would hide matches.
404
- local anchored = not literal and (string.sub(needle, 1, 1) == "^" or string.sub(needle, -1) == "$")
509
+ local anchored = false
510
+ if not literal then
511
+ for _, needle in needles do
512
+ if string.sub(needle, 1, 1) == "^" or string.sub(needle, -1) == "$" then
513
+ anchored = true
514
+ end
515
+ end
516
+ end
405
517
 
406
518
  local matches: { { [string]: any } } = {}
519
+ local counts: { number } = table.create(#needles, 0)
407
520
  local total = 0
521
+ local matchedFiles = 0
522
+ local budget = GREP_BUDGET
523
+ local full = false
408
524
  local memo: Paths.NameIndex = {}
409
525
 
526
+ -- Rows stop at `limit` or the budget, whichever comes first; `total` keeps
527
+ -- counting either way, so the cursor the server builds from the rows actually
528
+ -- shown picks up exactly where this reply stopped.
529
+ local function emit(entry: { [string]: any }, cost: number): boolean
530
+ if full or #matches >= limit then
531
+ return false
532
+ end
533
+ if cost > budget and #matches > 0 then
534
+ full = true
535
+ return false
536
+ end
537
+ budget -= cost
538
+ table.insert(matches, entry)
539
+ return true
540
+ end
541
+
410
542
  for _, target in targets do
411
543
  local source = ScriptEdit.read(target)
412
- if not anchored then
413
- local ok, found = pcall(string.find, if ignoreCase then string.lower(source) else source, needle, 1, literal)
414
- if not ok then
415
- badPattern(found)
416
- end
417
- if not found then
418
- continue
419
- end
544
+ local searchable = if ignoreCase then string.lower(source) else source
545
+ if not anchored and hits(searchable) == nil then
546
+ continue
420
547
  end
421
548
 
422
549
  local lines = TextEdit.toLines(source)
423
550
  local path: string? = nil
551
+ local revision: string? = nil
552
+ local fileMatches = 0
424
553
 
425
554
  for number, line in lines do
426
- local haystack = if ignoreCase then string.lower(line) else line
427
- -- An invalid Lua pattern raises rather than simply not matching, so it
428
- -- has to be caught and reported as a pattern problem, not a no-match.
429
- local ok, from = pcall(string.find, haystack, needle, 1, literal)
430
- if not ok then
431
- badPattern(from)
555
+ local found = hits(if ignoreCase then string.lower(line) else line)
556
+ if found == nil then
557
+ continue
558
+ end
559
+ fileMatches += 1
560
+ for _, index in found do
561
+ counts[index] += 1
432
562
  end
433
- if not from then
563
+ if mode ~= "lines" then
434
564
  continue
435
565
  end
436
566
 
437
567
  total += 1
438
- if total <= offset or #matches >= limit then
568
+ if total <= offset or full or #matches >= limit then
439
569
  continue
440
570
  end
441
571
 
442
- if not path then
443
- path = Paths.of(target, memo)
444
- end
445
-
572
+ path = path or Paths.of(target, memo)
573
+ revision = revision or ScriptEdit.revisionOf(target, source)
446
574
  local entry: { [string]: any } = {
447
575
  path = path,
576
+ revision = revision,
448
577
  line = number,
449
578
  text = line,
579
+ -- Only when there is more than one needle to tell apart.
580
+ needles = if #needles > 1 then found else nil,
450
581
  }
582
+ local cost = #line + 80
451
583
  if contextLines > 0 then
452
584
  local before: { string } = {}
453
585
  local after: { string } = {}
@@ -455,8 +587,38 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
455
587
  table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
456
588
  entry.before = before
457
589
  entry.after = after
590
+ for _, text in before do
591
+ cost += #text + 4
592
+ end
593
+ for _, text in after do
594
+ cost += #text + 4
595
+ end
596
+ end
597
+ -- A single line longer than the whole budget -- minified or generated
598
+ -- code -- is cut rather than never shown, which would stall the cursor.
599
+ if cost > budget and #matches == 0 then
600
+ entry.before, entry.after = nil, nil
601
+ entry.text = string.sub(line, 1, GREP_LONG_LINE)
602
+ entry.truncated = true
603
+ cost = GREP_LONG_LINE + 80
604
+ end
605
+ emit(entry, cost)
606
+ end
607
+
608
+ if fileMatches > 0 then
609
+ matchedFiles += 1
610
+ if mode == "files" then
611
+ total += 1
612
+ if total > offset then
613
+ path = path or Paths.of(target, memo)
614
+ emit({
615
+ path = path,
616
+ className = target.ClassName,
617
+ revision = ScriptEdit.revisionOf(target, source),
618
+ matches = fileMatches,
619
+ }, #(path :: string) + 60)
620
+ end
458
621
  end
459
- table.insert(matches, entry)
460
622
  end
461
623
  end
462
624
 
@@ -465,6 +627,8 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
465
627
  total = total,
466
628
  offset = offset,
467
629
  searched = #targets,
630
+ counts = if mode == "counts" then counts else nil,
631
+ matchedFiles = if mode == "counts" then matchedFiles else nil,
468
632
  }
469
633
  end
470
634
 
@@ -648,10 +812,30 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
648
812
 
649
813
  -- After the recording, because the editor write yields. Undoing the create
650
814
  -- still removes these scripts whole, source and all.
815
+ --
816
+ -- The scripts already exist at this point, so a failed write is reported as
817
+ -- what it is -- created, but without their source -- instead of as a failed
818
+ -- create an agent would retry into duplicates. And the write only fills a
819
+ -- script that is still empty: anything else is somebody else's text.
651
820
  for _, entry in oversized do
652
- ScriptEdit.write(entry.target, function()
653
- return entry.source
821
+ local ok, err = pcall(function()
822
+ Undo.exclusive(function()
823
+ ScriptEdit.write(entry.target, function(current)
824
+ if current ~= "" then
825
+ Dispatch.fail("STALE_SCRIPT", "edited before its source could be written; that text was kept")
826
+ end
827
+ return entry.source
828
+ end)
829
+ end)
654
830
  end)
831
+ if not ok then
832
+ local reason = if typeof(err) == "table" then err.message else tostring(err)
833
+ Dispatch.fail(
834
+ "PARTIAL_CREATE",
835
+ string.format("%s was created, but its source was not written: %s", Paths.of(entry.target), tostring(reason)),
836
+ "The script exists. Read it and write the source with script_edit rather than creating it again."
837
+ )
838
+ end
655
839
  end
656
840
 
657
841
  return {