romdevtools 0.28.0 → 0.30.0

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 (179) hide show
  1. package/AGENTS.md +53 -43
  2. package/CHANGELOG.md +91 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +12 -12
  88. package/src/cores/wasm/bluemsx_libretro.js +1 -1
  89. package/src/cores/wasm/bluemsx_libretro.wasm +0 -0
  90. package/src/cores/wasm/fceumm_libretro.js +1 -1
  91. package/src/cores/wasm/fceumm_libretro.wasm +0 -0
  92. package/src/cores/wasm/gambatte_libretro.js +1 -1
  93. package/src/cores/wasm/gambatte_libretro.wasm +0 -0
  94. package/src/cores/wasm/geargrafx_libretro.js +1 -1
  95. package/src/cores/wasm/geargrafx_libretro.wasm +0 -0
  96. package/src/cores/wasm/genesis_plus_gx_libretro.js +1 -1
  97. package/src/cores/wasm/genesis_plus_gx_libretro.wasm +0 -0
  98. package/src/cores/wasm/handy_libretro.js +1 -1
  99. package/src/cores/wasm/handy_libretro.wasm +0 -0
  100. package/src/cores/wasm/mgba_libretro.js +1 -1
  101. package/src/cores/wasm/mgba_libretro.wasm +0 -0
  102. package/src/cores/wasm/prosystem_libretro.js +1 -1
  103. package/src/cores/wasm/prosystem_libretro.wasm +0 -0
  104. package/src/cores/wasm/snes9x_libretro.js +1 -1
  105. package/src/cores/wasm/snes9x_libretro.wasm +0 -0
  106. package/src/cores/wasm/stella2014_libretro.js +1 -1
  107. package/src/cores/wasm/stella2014_libretro.wasm +0 -0
  108. package/src/cores/wasm/vice_x64_libretro.js +1 -1
  109. package/src/cores/wasm/vice_x64_libretro.wasm +0 -0
  110. package/src/host/LibretroHost.js +84 -8
  111. package/src/http/tool-registry.js +11 -11
  112. package/src/mcp/tools/cheats.js +2 -1
  113. package/src/mcp/tools/frame.js +3 -2
  114. package/src/mcp/tools/index.js +3 -3
  115. package/src/mcp/tools/input.js +5 -4
  116. package/src/mcp/tools/lifecycle.js +6 -4
  117. package/src/mcp/tools/memory.js +131 -24
  118. package/src/mcp/tools/platform-docs.js +1 -1
  119. package/src/mcp/tools/preview-tile.js +6 -2
  120. package/src/mcp/tools/project.js +1098 -130
  121. package/src/mcp/tools/record.js +6 -7
  122. package/src/mcp/tools/rom-id.js +5 -1
  123. package/src/mcp/tools/run-until.js +12 -4
  124. package/src/mcp/tools/snippets.js +6 -6
  125. package/src/mcp/tools/sprite-pipeline.js +14 -2
  126. package/src/mcp/tools/state.js +2 -1
  127. package/src/mcp/tools/tile-inspect.js +8 -1
  128. package/src/mcp/tools/toolchain.js +12 -1
  129. package/src/mcp/tools/watch-memory.js +53 -10
  130. package/src/observer/bus.js +73 -0
  131. package/src/observer/livestream.html +4 -2
  132. package/src/observer/tool-wrap.js +17 -14
  133. package/src/platforms/_guides/ROMHACKING_PLAYBOOK.md +32 -3
  134. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  135. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  136. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  137. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  138. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  139. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  140. package/src/platforms/gb/lib/c/README.md +10 -11
  141. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  142. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  143. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  144. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  145. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  146. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  147. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  148. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  149. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  150. package/src/platforms/gbc/lib/c/README.md +10 -11
  151. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  152. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  153. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  154. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  155. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  156. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  157. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  158. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  159. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  160. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  161. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  162. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  163. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  164. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  165. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  166. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  167. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  168. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  169. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  170. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  171. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  172. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  173. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  174. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  175. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  176. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  177. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  178. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  179. package/src/toolchains/index.js +27 -11
@@ -12,13 +12,12 @@ import { mkdir, writeFile } from "node:fs/promises";
12
12
  import path from "node:path";
13
13
  import { getHost } from "../state.js";
14
14
  import { jsonContent, safeTool } from "../util.js";
15
- import { MemoryRegionToRetro } from "../../host/types.js";
16
-
17
- // Single source of truth for memorySamples regions — the same canonical set
18
- // readMemory accepts. Previously hardcoded to 8 NES regions, so Genesis and
19
- // hardware-register regions (nes_apu_regs, etc.) couldn't be batch-sampled.
20
- const SAMPLE_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
21
15
 
16
+ // memorySamples regions accept the same canonical set readMemory accepts (incl.
17
+ // hardware-register regions like nes_apu_regs). The region is a runtime-validated
18
+ // string rather than an inlined ~62-value schema enum — the per-sample
19
+ // host.readMemory(region,…) lookup throws on an unknown region with a clear
20
+ // message, so the schema enum was pure deferred-load weight (0.28.0 feedback #5).
22
21
  export function registerRecordTools(server, z, sessionKey) {
23
22
  const inputShape = z.object({
24
23
  up: z.boolean().optional(), down: z.boolean().optional(),
@@ -54,7 +53,7 @@ export function registerRecordTools(server, z, sessionKey) {
54
53
  .array(
55
54
  z.object({
56
55
  label: z.string(),
57
- region: z.enum(SAMPLE_REGIONS),
56
+ region: z.string().describe("memory region (full readMemory set incl. hardware registers; validated at runtime)"),
58
57
  offset: z.number().int().min(0),
59
58
  length: z.number().int().min(1).max(256),
60
59
  }),
@@ -305,13 +305,17 @@ export async function extractSpriteSheetCore({ platform, path: romPath, offset,
305
305
  const path = await import("node:path");
306
306
  await mkdir(path.dirname(outputPath), { recursive: true });
307
307
  await writeFile(outputPath, png);
308
- return jsonContent({
308
+ // Livestream sideband: show the rendered sheet even though the agent
309
+ // only gets the path.
310
+ const out = jsonContent({
309
311
  path: outputPath,
310
312
  intent: d.intent,
311
313
  bytes: png.length,
312
314
  paletteSource,
313
315
  note,
314
316
  });
317
+ out._observerImages = [{ kind: "image", mimeType: "image/png", base64: png.toString("base64") }];
318
+ return out;
315
319
  }
316
320
  return {
317
321
  content: [
@@ -10,11 +10,18 @@
10
10
 
11
11
  import { getHost } from "../state.js";
12
12
  import { jsonContent, safeTool } from "../util.js";
13
+ import { attachObserverFrame } from "./watch-memory.js";
13
14
 
14
15
  export function registerRunUntilTools(server, z, sessionKey) {
16
+ // Condition `region` is a runtime-validated string, not a schema enum. It was
17
+ // an inlined 8-value list — which both bloated the schema AND silently rejected
18
+ // valid non-NES regions (genesis_*, c64_*, *_apu_regs) that host.readMemory
19
+ // accepts. The readMemory(region,…) call in the handler validates and throws a
20
+ // clear message on an unknown region (full canonical set, same as `memory`).
21
+ const regionStr = z.string().describe("memory region (full readMemory set, e.g. system_ram, nes_oam, genesis_vram, c64_color_ram; validated at runtime)");
15
22
  const memoryCondition = z.object({
16
23
  type: z.literal("memory"),
17
- region: z.enum(["system_ram", "save_ram", "video_ram", "rtc", "nes_nametables", "nes_palette", "nes_oam", "nes_chr"]),
24
+ region: regionStr,
18
25
  offset: z.number().int().min(0),
19
26
  equals: z.number().int().min(0).max(255).optional(),
20
27
  notEquals: z.number().int().min(0).max(255).optional(),
@@ -23,7 +30,7 @@ export function registerRunUntilTools(server, z, sessionKey) {
23
30
 
24
31
  const memoryChangedCondition = z.object({
25
32
  type: z.literal("memoryChanged"),
26
- region: z.enum(["system_ram", "save_ram", "video_ram", "rtc", "nes_nametables", "nes_palette", "nes_oam", "nes_chr"]),
33
+ region: regionStr,
27
34
  offset: z.number().int().min(0),
28
35
  length: z.number().int().min(1).max(8192).default(1),
29
36
  }).describe("Stop when memory[region][offset..offset+length] changes from its initial value.");
@@ -75,11 +82,12 @@ export function registerRunUntilTools(server, z, sessionKey) {
75
82
  }
76
83
  }
77
84
 
78
- return jsonContent({
85
+ // Livestream: the frame where the condition was met (or where we gave up).
86
+ return attachObserverFrame(jsonContent({
79
87
  conditionMet: met,
80
88
  framesStepped,
81
89
  finalValue,
82
- });
90
+ }), host, met ? "runUntil: condition met" : "runUntil: gave up");
83
91
  }),
84
92
  );
85
93
  }
@@ -97,11 +97,11 @@ export function registerSnippetTools(_server, _z) {
97
97
  platform, languages, snippets: filtered,
98
98
  note: filtered.length === 0
99
99
  ? `No snippets matched for '${platform}'${language ? ` (language=${language})` : ""}.`
100
- : `Fetch one with starterSnippets({ platform, mode:'get', name${language ? ", language" : ""} }), or all with mode:'getAll'.`,
100
+ : `Fetch one with examples({ op:'snippets', platform, mode:'get', snippetName${language ? ", language" : ""} }), or all with mode:'getAll'.`,
101
101
  });
102
102
  }
103
103
  async function snippetsGet(platform, name, language) {
104
- if (!name) throw new Error("starterSnippets mode:'get' requires `name`.");
104
+ if (!name) throw new Error("examples({op:'snippets'}) mode:'get' requires `snippetName`.");
105
105
  if (name.includes("..") || (name.includes("/") && !/^[a-z]+\/[\w.-]+$/i.test(name))) {
106
106
  throw new Error("snippet name must not contain '..' or arbitrary path separators");
107
107
  }
@@ -124,7 +124,7 @@ export function registerSnippetTools(_server, _z) {
124
124
  return textContent(`No snippets available for '${platform}'${language ? ` (language=${language})` : ""}.`);
125
125
  }
126
126
  if (!inline && !outputPath) {
127
- throw new Error("starterSnippets mode:'getAll': pass outputPath (write the joined snippets to disk, returns {path}) or inline:true (return `combined` in the response). Or use copyStarterSnippets to write each snippet as its own file.");
127
+ throw new Error("examples({op:'snippets'}) mode:'getAll': pass outputPath (write the joined snippets to disk, returns {path}) or inline:true (return `combined` in the response). Or use examples({op:'copySnippets'}) to write each snippet as its own file.");
128
128
  }
129
129
  const parts = [];
130
130
  for (const s of filtered) {
@@ -161,7 +161,7 @@ export function registerSnippetTools(_server, _z) {
161
161
  if (filtered.length === 0) {
162
162
  const langPart = language ? ` (language=${language})` : "";
163
163
  const includePart = include ? ` (include=${JSON.stringify(include)})` : "";
164
- throw new Error(`copyStarterSnippets: no snippets matched for platform '${platform}'${langPart}${includePart}.`);
164
+ throw new Error(`examples({op:'copySnippets'}): no snippets matched for platform '${platform}'${langPart}${includePart}.`);
165
165
  }
166
166
  await mkdir(destinationDir, { recursive: true });
167
167
  const written = [];
@@ -201,9 +201,9 @@ export function registerSnippetTools(_server, _z) {
201
201
  };
202
202
  }
203
203
 
204
- // starterSnippets/copyStarterSnippets folded into the `scaffold` tool. The cores
204
+ // starterSnippets/copyStarterSnippets folded into the `examples` tool. The cores
205
205
  // are assigned inside registerSnippetTools (they close over the local helpers);
206
- // scaffold imports these and calls them. registerSnippetTools registers NO tools
206
+ // examples imports these and calls them. registerSnippetTools registers NO tools
207
207
  // now — it just wires the cores.
208
208
  export let starterSnippetsCore = async () => { throw new Error("snippet cores not initialized — registerSnippetTools must run first"); };
209
209
  export let copyStarterSnippetsCore = async () => { throw new Error("snippet cores not initialized — registerSnippetTools must run first"); };
@@ -143,6 +143,7 @@ async function cropSpriteSheetImpl({ path, tileX, tileY, tileW, tileH, tileSize
143
143
  }
144
144
  await writeFile(outputPath, outBuf);
145
145
  return {
146
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: outBuf.toString("base64") }],
146
147
  path: outputPath,
147
148
  intent: d.intent,
148
149
  width: pxW,
@@ -157,6 +158,16 @@ async function cropSpriteSheetImpl({ path, tileX, tileY, tileW, tileH, tileSize
157
158
  };
158
159
  }
159
160
 
161
+ // Lift a plain core result's livestream sideband OUT of the JSON body and
162
+ // onto the MCP result object (it must never serialize into agent-visible text).
163
+ function liftObserverImages(r) {
164
+ const sideband = r._observerImages;
165
+ delete r._observerImages;
166
+ const out = jsonContent(r);
167
+ if (sideband) out._observerImages = sideband;
168
+ return out;
169
+ }
170
+
160
171
  // ── quantizePngForPlatform ──────────────────────────────────────────
161
172
 
162
173
  /**
@@ -247,6 +258,7 @@ async function quantizePngForPlatformImpl({ path, platform, outputPath, intent,
247
258
  const outBuf = writeIndexedPng(png.width, png.height, indices, palette);
248
259
  await writeFile(outputPath, outBuf);
249
260
  return {
261
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: outBuf.toString("base64") }],
250
262
  path: outputPath,
251
263
  intent: d.intent,
252
264
  width: png.width,
@@ -596,12 +608,12 @@ export function registerSpritePipelineTools(server, z, _sessionKey) {
596
608
  case "quantize": {
597
609
  if (!args.platform) throw new Error("encodeArt({stage:'quantize'}): `platform` is required.");
598
610
  if (!args.path || !args.outputPath) throw new Error("encodeArt({stage:'quantize'}): `path` and `outputPath` are required.");
599
- return jsonContent(await quantizePngForPlatformImpl(args));
611
+ return liftObserverImages(await quantizePngForPlatformImpl(args));
600
612
  }
601
613
  case "crop": {
602
614
  if (!args.path || !args.outputPath) throw new Error("encodeArt({stage:'crop'}): `path` and `outputPath` are required.");
603
615
  if (args.tileX == null || args.tileY == null || args.tileW == null || args.tileH == null) throw new Error("encodeArt({stage:'crop'}): `tileX`, `tileY`, `tileW`, `tileH` are required.");
604
- return jsonContent(await cropSpriteSheetImpl(args));
616
+ return liftObserverImages(await cropSpriteSheetImpl(args));
605
617
  }
606
618
  case "tiles": {
607
619
  if (!args.platform) throw new Error("encodeArt({stage:'tiles'}): `platform` is required.");
@@ -1,6 +1,7 @@
1
1
  import { mkdir, writeFile, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { getHost } from "../state.js";
4
+ import { attachObserverFrame } from "./watch-memory.js";
4
5
  import { jsonContent, safeTool } from "../util.js";
5
6
 
6
7
  // Resolve a state-file `path`. An ABSOLUTE path is used as-is. A RELATIVE path
@@ -342,7 +343,7 @@ export function registerStateTools(server, z, sessionKey) {
342
343
  safeTool(async (args) => {
343
344
  switch (args.op) {
344
345
  case "save": return jsonContent(await saveStateCore(args, sessionKey));
345
- case "load": return jsonContent(await loadStateCore(args, sessionKey));
346
+ case "load": return attachObserverFrame(jsonContent(await loadStateCore(args, sessionKey)), getHost(sessionKey), `state load ${args.name ?? args.path ?? ""}`.trim());
346
347
  case "list": return jsonContent(listStatesCore(args, sessionKey));
347
348
  case "export": {
348
349
  if (!args.fromSlot) throw new Error("state({op:'export'}): `fromSlot` is required.");
@@ -284,7 +284,14 @@ export function registerTileInspectTools(server, z, sessionKey) {
284
284
  if (r.pngBase64) {
285
285
  return { content: [imageContent(r.pngBase64), textContent(JSON.stringify({ ...r, pngBase64: undefined }))] };
286
286
  }
287
- return jsonContent(r);
287
+ // Lift the livestream sideband OUT of the core's plain result before
288
+ // it's serialized — it must ride on the MCP result object, never in
289
+ // the agent-visible JSON text.
290
+ const sideband = r._observerImages;
291
+ delete r._observerImages;
292
+ const out = jsonContent(r);
293
+ if (sideband) out._observerImages = sideband;
294
+ return out;
288
295
  }
289
296
  default: throw new Error(`tiles: unknown op '${args.op}'`);
290
297
  }
@@ -703,7 +703,7 @@ export function registerToolchainTools(server, z, sessionKey) {
703
703
  "• output:'rom' (default) — assemble or compile `source` (single) / `sources` ({name:contents}) / `sourcePath` / `sourcesPaths`. Returns the ROM (path by default; `inline:true` for binaryBase64) + build log. **`binaryIncludes`/`binaryIncludePaths` (base64/path CHR-ROM, music blobs for `.incbin`) — WITHOUT them no game with external assets builds.** `includes`/`includePaths` for `.include`d text. `linkerConfig` (cc65; NES preset 'chr-ram-runtime' RECOMMENDED). `crt0`/`crt0Path`/`codeLoc`/`dataLoc` (SDCC). `runtime`/`maxmod`/`rebuildSdk` (GBA/Genesis SDK). **`lint:'strict'` fails the build (stage:'lint', no binary) if the pre-flight SDCC crash-pattern scan flags anything (e.g. the uint8 loop-bound trap); 'advisory' (default) just lists hits in issues[].** **`includeSymbols:true` returns the .map text inline on a PLAIN rom build — distinct from output:'romWithDebug' which writes .dbg/.map FILES.** Language is inferred from extension/content — usually OMIT `language`.\n" +
704
704
  "• output:'romWithDebug' — like 'rom' but also emits linker debug info for the `symbols` tool: cc65 → `.dbg`, SDCC → sdld `.map`, Genesis m68k → GNU ld map (find where a RAM var landed). DEFAULT writes ROM + debug file + log to disk (`outputPath` required unless `inline:true`). **`resolveSymbols:['grid','score']` folds those names' addresses ({resolvedSymbols:{grid:{address,hex,region?,ramOffset?}}}) straight into the result — the cheap way to a WRAM variable's address without loading the whole map (or round-tripping it through `symbols`).**\n" +
705
705
  "• output:'run' — BUILD + LOAD + RUN + SCREENSHOT in one round trip — the fastest iteration loop. Same build args; runs `frames` frames and returns the screenshot INLINE. `holdInputs` holds controller state; `screenshotPath` writes the PNG to disk instead; `projectName` titles the playtest window.\n" +
706
- "• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for a scaffold({op:'project'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a scaffolded project.**",
706
+ "• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for an examples({op:'fork'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a forked project.**",
707
707
  {
708
708
  output: z.enum(["rom", "romWithDebug", "run", "project"])
709
709
  .describe("rom=produce a ROM (default); romWithDebug=ROM + .dbg/.map debug files; run=build+load+run+screenshot; project=build a project directory."),
@@ -838,6 +838,17 @@ export function projectBuildRecipe(platform, names) {
838
838
  // commercial ROMs boot in the same host; only our scaffolds failed). Routing
839
839
  // msx_crt0.s through crt0 makes ITS header + init the cartridge entry.
840
840
  if (has("msx_crt0.s")) { r.crt0File = "msx_crt0.s"; r.codeLoc = 0x4010; }
841
+ } else if (platform === "pce") {
842
+ // PCE example projects (they ship pce_hw.h) build on the 'rom32k' preset:
843
+ // a 32KB HuCard with bank 0 (STARTUP/VECTORS) FIRST in the file at $E000
844
+ // and banks 1-3 (CODE/RODATA) at $8000-$DFFF, where cc65's pce crt0 TAMs
845
+ // them before main(). cc65's stock pce.cfg is an 8KB boot bank — too small
846
+ // for a complete example game — and its documented 32K variant places the
847
+ // vectors in the LAST file bank, which a HuCard never maps at reset
848
+ // (verified black screen on geargrafx). An 8KB-sized program still links
849
+ // and boots identically under this preset, so it's safe for every
850
+ // pce_hw.h-style project. Bare hand-rolled dirs are left alone.
851
+ if (has("pce_hw.h")) r.linkerConfig = "rom32k";
841
852
  } else if (platform === "sms" || platform === "gg") {
842
853
  // SMS/GG: route the project's *_crt0.s through the crt0 channel (like
843
854
  // GB/MSX), NOT as a plain source TU. The OLD recipe skipped it on the
@@ -56,7 +56,7 @@ async function maybeRestoreState(host, fromState, fromStatePath) {
56
56
  // observer wrapper encodes it ASYNCHRONOUSLY, after the agent's response has
57
57
  // already gone out. The provider is stripped from the agent-visible result. The
58
58
  // frame is captured by reference now (correct frozen state) but rasterized later.
59
- export function attachObserverFrame(json, host) {
59
+ export function attachObserverFrame(json, host, caption) {
60
60
  json._observerFrameProvider = () => {
61
61
  try {
62
62
  const shot = host.screenshot(); // { pngBase64, width, height }
@@ -65,6 +65,7 @@ export function attachObserverFrame(json, host) {
65
65
  : null;
66
66
  } catch { return null; }
67
67
  };
68
+ if (caption) json._observerFrameCaption = String(caption);
68
69
  return json;
69
70
  }
70
71
 
@@ -138,6 +139,19 @@ export function makePressDriver(host, presses) {
138
139
  // never disagree again.
139
140
  const MEMORY_REGIONS = /** @type {[string, ...string[]]} */ (Object.keys(MemoryRegionToRetro));
140
141
 
142
+ // A region param that does NOT inline the full ~62-value enum into the JSON
143
+ // schema. The enum array is ~214 tokens PER param site; inlining it on every
144
+ // secondary region sub-param across this file was the dominant tool-schema
145
+ // bloat (0.28.0 feedback #5). Used on SECONDARY/sub params; the PRIMARY region
146
+ // inputs keep z.enum so the full list stays discoverable where the region IS
147
+ // the choice. A plain string — validated at RUNTIME by the handler (the
148
+ // host.readMemory / MemoryRegionToRetro lookup throws on an unknown region with
149
+ // a clear message), so dropping the schema enum here costs no safety.
150
+ // NOTE: `z` is passed into registerWatchMemoryTools (not a module import), so
151
+ // this factory takes `z` and is invoked once inside the register fn.
152
+ const makeRegionStr = (z) => (desc) =>
153
+ z.string().describe(desc + " (validated at runtime against the canonical region set).");
154
+
141
155
  // Abort-guard for input-driven watchpoint runs: sample caller-named bytes each
142
156
  // frame; the FIRST one to change stops the run with {label,addr,before,after}.
143
157
  // Lets a derailed driven scenario (player died, scene flipped) return immediately
@@ -265,8 +279,9 @@ function downsample(arr, n) {
265
279
  }
266
280
 
267
281
  export function registerWatchMemoryTools(server, z, sessionKey) {
282
+ const regionStr = makeRegionStr(z);
268
283
  const rangeShape = z.object({
269
- region: z.enum(MEMORY_REGIONS),
284
+ region: regionStr("memory region for THIS range (same canonical set `memory` uses)"),
270
285
  offset: z.number().int().min(0),
271
286
  length: z.number().int().min(1).max(4096).default(1),
272
287
  label: z.string().optional().describe("Name echoed on every event from this range — tells disjoint ranges apart in one stream."),
@@ -509,7 +524,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
509
524
 
510
525
  // breakpoint({on:write|read|pc}) STOP-on-first. on:write precision:exact=bpFindWriter
511
526
  // (core watchpoint, true PC under IRQ), precision:sampled=bpRunUntilWrite (frame PC).
512
- async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf }) {
527
+ async function bpFindWriter({ address, maxFrames = 600, pressDuring, abortIf, condition, conditionValue }) {
513
528
  const host = getHost(sessionKey);
514
529
  if (!host.watchpointSupported || !host.watchpointSupported()) {
515
530
  return jsonContent({
@@ -518,7 +533,18 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
518
533
  "Use watchMemory/runUntilWrite here — their pc is frame-sampled, so cross-check the value trace.",
519
534
  });
520
535
  }
521
- host.setWatchpoint(address, true);
536
+ if (condition === "equals" && conditionValue == null) {
537
+ throw new Error("breakpoint({on:'write', condition:'equals'}): `conditionValue` (the byte to stop on) is required.");
538
+ }
539
+ // Pass the condition to the core's watchpoint so its hook only COUNTS +
540
+ // records writes that satisfy it (qualifying writes), ignoring restoring/
541
+ // churn writes — and so the reported PC is a meaningful write, not just the
542
+ // last write of the frame. Core support is feature-detected; if the loaded
543
+ // core build predates condition support, we fall back to a host-side
544
+ // 'equals' filter on the reported value (inc/dec need the core's old byte).
545
+ const wantCond = condition != null;
546
+ const coreCond = host.setWatchpoint(address, true, wantCond ? { condition, value: conditionValue } : undefined);
547
+ const coreHandledCond = wantCond && coreCond && coreCond.conditionApplied === true;
522
548
  const presses = (pressDuring ?? []).slice().sort((a, b) => a.frame - b.frame);
523
549
  const pressDriver = makePressDriver(host, presses);
524
550
  // Abort-guard: sample caller-named "still valid?" bytes each frame; if any
@@ -532,7 +558,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
532
558
  pressDriver.applyForFrame(i);
533
559
  host.stepFrames(1);
534
560
  const w = host.getWatchpoint();
535
- if (w.hits > 0) { result = { ...w, framesStepped: i + 1 }; break; }
561
+ if (w.hits > 0) {
562
+ // Host-side fallback for condition:'equals' on a core that didn't
563
+ // apply the condition itself: only accept when the reported (last)
564
+ // written value equals the target; otherwise keep waiting. (inc/dec
565
+ // can't be faked host-side — they need the core's pre-write byte, so
566
+ // we only reach here for them when the core DID handle the condition.)
567
+ if (wantCond && !coreHandledCond && condition === "equals" && (w.lastValue & 0xFF) !== (conditionValue & 0xFF)) {
568
+ continue;
569
+ }
570
+ result = { ...w, framesStepped: i + 1 }; break;
571
+ }
536
572
  const ab = guard.check();
537
573
  if (ab) { aborted = { ...ab, framesStepped: i + 1 }; break; }
538
574
  }
@@ -583,12 +619,17 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
583
619
  // address — a word/long store shows only its byte here, not the operand
584
620
  // (a real session read 0x00 as "the move.l wrote zero").
585
621
  valueByte: "0x" + result.lastValue.toString(16).toUpperCase().padStart(2, "0"),
622
+ ...(result.lastOldValue != null ? { oldValueByte: "0x" + (result.lastOldValue & 0xFF).toString(16).toUpperCase().padStart(2, "0") } : {}),
623
+ ...(condition ? { condition, ...(coreHandledCond ? {} : { conditionAppliedBy: "host" }) } : {}),
586
624
  hits: result.hits,
587
625
  framesStepped: result.framesStepped,
588
626
  ...(wpRegs ? { registersAtHit: wpRegs } : {}),
589
627
  ...(bankInfo ? bankInfo : {}),
590
628
  ...(presses.length ? { pressesScheduled: presses.length, pressesApplied: pressDriver.applied() } : {}),
591
629
  note: "pc is the EXACT writing instruction (captured in the CPU write path), not a frame sample. " +
630
+ (condition
631
+ ? `condition:'${condition}' filtered to the MEANINGFUL write — pc/valueByte/hits reflect only qualifying writes${result.lastOldValue != null ? ` (oldValueByte→valueByte = ${"0x" + (result.lastOldValue & 0xFF).toString(16)}→${"0x" + result.lastValue.toString(16)})` : ""}. `
632
+ : "Without a `condition`, on:'write' runs to END OF FRAME and reports the LAST matching write of the frame (NOT the first) — `hits` is the count of all matching writes that frame. If a restoring/churn write hides the change you want, pass condition:'increase'|'decrease'|'equals'. ") +
592
633
  "valueByte is the single byte written to the watched address (a 16/32-bit store shows only its byte here). " +
593
634
  "hits counts watched-byte writes during the hit frame — the same instruction looping twice in one frame is hits:2, one event. " +
594
635
  (wpRegs ? "registersAtHit is the register file frozen AT the write (the live regs drift for the rest of the frame — don't cpu({op:'read'}) instead). " : "") +
@@ -829,9 +870,11 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
829
870
  precision: z.enum(["exact", "sampled"]).default("exact")
830
871
  .describe("on:'write' ONLY. exact=core watchpoint, the real writing instruction PC even under interrupts (uses `address`). sampled=cheap frame-boundary PC (uses region/offset/length) — NOT the writer under IRQ. Ignored for on:read/pc (always exact)."),
831
872
  address: z.number().int().min(0).optional().describe("on:'write' exact / on:'read' / on:'pc' — CPU address to break on (write target, read target, or instruction boundary). Required for those."),
832
- region: z.enum(MEMORY_REGIONS).optional().describe("on:'write' precision:'sampled' — region whose byte to watch for change."),
873
+ region: regionStr("on:'write' precision:'sampled' — region whose byte to watch for change.").optional(),
833
874
  offset: z.number().int().min(0).optional().describe("on:'write' precision:'sampled' — offset within the region."),
834
875
  length: z.number().int().min(1).max(4096).default(1).describe("on:'write' precision:'sampled' — bytes to watch from offset."),
876
+ condition: z.enum(["increase", "decrease", "equals"]).optional().describe("on:'write' precision:'exact' ONLY — stop only on the MEANINGFUL write, ignoring restoring/churn writes. 'decrease'/'increase' = the stored byte actually went down/up (e.g. a real lives−1, not a per-frame pointer-arithmetic restore); 'equals' = the byte became `value` (e.g. $00→$01 respawn re-arm). Without it, on:'write' reports the LAST matching write of the frame, which may be the churn, not the change you want."),
877
+ conditionValue: z.number().int().min(0).max(255).optional().describe("on:'write' condition:'equals' — the byte value to stop on (the NEW value written)."),
835
878
  maxFrames: z.number().int().min(1).max(1_000_000).default(600).describe("Max frames to run while waiting for the condition."),
836
879
  pressDuring: z.array(z.object({
837
880
  frame: z.number().int().min(0),
@@ -840,12 +883,12 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
840
883
  holdFrames: z.number().int().min(1).default(2),
841
884
  })).optional().describe("Schedule input while waiting (drive the game to the state that triggers the condition). If OMITTED, this run inherits whatever input({op:'set'}) last held — same as frame({op:'step'}). If GIVEN, the schedule OWNS the pad for the whole run (a prior input({op:'set'}) is ignored); use it to drive the watched window itself. Entries with OVERLAPPING windows on the same port are OR'd into a chord (e.g. b+right held while a fires mid-window), not overwritten."),
842
885
  abortIf: z.array(z.object({
843
- region: z.enum(MEMORY_REGIONS).optional().describe("memory region (default system_ram)"),
886
+ region: regionStr("memory region (default system_ram)").optional(),
844
887
  offset: z.number().int().min(0).describe("byte offset within the region"),
845
888
  label: z.string().optional().describe("human name for this guard byte"),
846
889
  })).optional().describe("on:'write' exact — ABORT GUARD for a pressDuring run: caller-named 'is this scenario still valid?' bytes (e.g. the area/scene id, the player object-active flag). If ANY changes mid-run the watchpoint stops IMMEDIATELY and returns {aborted:true, abortedBy, before, after} — so a driven scenario that derailed (player died → title screen) doesn't burn all maxFrames and return a meaningless found:false. Each is sampled once per frame (cheap)."),
847
890
  captureMemory: z.array(z.object({
848
- region: z.enum(MEMORY_REGIONS).describe("memory region to read"),
891
+ region: regionStr("memory region to read"),
849
892
  offset: z.number().int().min(0).describe("byte offset within the region"),
850
893
  length: z.number().int().min(1).max(256).default(1).describe("bytes to read"),
851
894
  label: z.string().optional().describe("human name for this read (else 'region+offset')"),
@@ -927,7 +970,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
927
970
  ? `Stopped at ${r.stoppedAtPC} (your stopAtPC) with PARTIAL output — readMemory the dst to see what's been written so far.`
928
971
  : "Did not return within maxFrames AND the watchdog didn't trip — this usually means the entry FELL BACK INTO THE GAME (a wrapper PC with a wrong source, so it never reaches the sentinel) and the game is just free-running. finalPC is inside the main loop, not your routine. Re-check the entry PC (use the routine body, not a wrapper) and the source regs; or lower maxInstructions to fail fast while probing. Bump maxFrames/maxInstructions only if you're sure it's a legitimately huge decompress.")
929
972
  + frameLogicCaveat;
930
- return jsonContent({
973
+ return attachObserverFrame(jsonContent({
931
974
  returned: r.returned, framesRun: r.framesRun, sandbox,
932
975
  ...(r.pure ? { pure: true, pureMode: r.pureMode } : {}),
933
976
  ...(r.watchdog ? { watchdog: true, reason: r.reason } : {}),
@@ -935,7 +978,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
935
978
  ...(r.finalPC ? { finalPC: r.finalPC } : {}),
936
979
  ...(r.finalRegs ? { finalRegs: r.finalRegs } : {}),
937
980
  note,
938
- });
981
+ }), host, "cpu call");
939
982
  }
940
983
 
941
984
  async function cpuDecompress({ entryPC, sourceAddress, destAddress, maxFrames = 600 }) {
@@ -80,6 +80,79 @@ class ObserverBus extends EventEmitter {
80
80
 
81
81
  export const observer = new ObserverBus();
82
82
 
83
+ // ── Throttled deferred-frame emission ───────────────────────────────────────
84
+ // `call_frame` events carry a freshly-rasterized framebuffer PNG for the
85
+ // human's livestream. Tools attach a PROVIDER thunk (attachObserverFrame) and
86
+ // both transports route it here. Two guarantees:
87
+ // 1. The PNG encode NEVER runs on the agent's critical path (deferred via
88
+ // setImmediate / the trailing timer).
89
+ // 2. Rate-limited to one frame per FRAME_MIN_INTERVAL_MS **per
90
+ // (session, tool)** — frame({op:'step'}) called 120× in a narrowing loop
91
+ // emits at most every 2s, but a step followed immediately by a DIFFERENT
92
+ // tool's frame (input, state load, …) still shows: distinct tools don't
93
+ // throttle each other. Trailing-edge: the LAST suppressed frame in a
94
+ // burst always lands when the window reopens (rendered at fire time =
95
+ // the current screen, which is exactly what the human wants to converge
96
+ // on).
97
+ let FRAME_MIN_INTERVAL_MS = 2000;
98
+ export function _setFrameThrottleForTest(ms) { FRAME_MIN_INTERVAL_MS = ms; }
99
+
100
+ /** @type {Map<string, {lastTs: number, timer: any, pending: null | {provider: Function, meta: object}}>} */
101
+ const _frameThrottle = new Map();
102
+
103
+ function _emitFrame(provider, meta) {
104
+ try {
105
+ const img = provider();
106
+ if (img) {
107
+ observer.push({
108
+ type: "call_frame",
109
+ sessionKey: meta.sessionKey ?? "http",
110
+ platform: typeof meta.resolvePlatform === "function" ? (meta.resolvePlatform() ?? meta.platform ?? null) : (meta.platform ?? null),
111
+ ts: meta.ts ?? Date.now(),
112
+ tool: meta.tool,
113
+ ...(meta.caption ? { caption: meta.caption } : {}),
114
+ images: [img],
115
+ });
116
+ }
117
+ } catch { /* livestream is best-effort; never affects the agent */ }
118
+ }
119
+
120
+ /**
121
+ * Queue a deferred framebuffer for the livestream, throttled per
122
+ * (session, tool). `meta`: { sessionKey, tool, ts?, platform?,
123
+ * resolvePlatform?, caption? } — resolvePlatform (a thunk) is preferred so
124
+ * the platform label reflects post-call state (loadMedia sets it DURING the
125
+ * call). `provider` returns {kind:'image', mimeType, base64} or null; it is
126
+ * invoked OFF the agent's critical path.
127
+ */
128
+ export function pushObserverFrame(meta, provider) {
129
+ const key = `${meta.sessionKey ?? "http"}|${meta.tool ?? "?"}`;
130
+ let st = _frameThrottle.get(key);
131
+ if (!st) { st = { lastTs: 0, timer: null, pending: null }; _frameThrottle.set(key, st); }
132
+ const now = Date.now();
133
+ if (!st.timer && now - st.lastTs >= FRAME_MIN_INTERVAL_MS) {
134
+ st.lastTs = now;
135
+ setImmediate(() => _emitFrame(provider, meta));
136
+ return;
137
+ }
138
+ // Inside the window: stash as the pending trailing frame (latest wins) and
139
+ // arm the trailing timer once.
140
+ st.pending = { provider, meta };
141
+ if (!st.timer) {
142
+ const delay = Math.max(1, st.lastTs + FRAME_MIN_INTERVAL_MS - now);
143
+ st.timer = setTimeout(() => {
144
+ st.timer = null;
145
+ const p = st.pending;
146
+ st.pending = null;
147
+ if (p) {
148
+ st.lastTs = Date.now();
149
+ _emitFrame(p.provider, p.meta);
150
+ }
151
+ }, delay);
152
+ if (st.timer.unref) st.timer.unref(); // never hold the process open
153
+ }
154
+ }
155
+
83
156
  /**
84
157
  * Extract image payloads from an MCP tool result. MCP tool results have
85
158
  * `content: [{type:'text'|'image', ...}]`. We pull out images so the UI
@@ -305,7 +305,8 @@
305
305
  // One latest image per "tool" (kind = tool name); ev.tool
306
306
  // identifies which inspect call produced it.
307
307
  s.latestByKind[ev.tool] = { ts: ev.ts, base64: img.base64,
308
- mimeType: img.mimeType, tool: ev.tool, platform: ev.platform ?? null };
308
+ mimeType: img.mimeType, tool: ev.tool, platform: ev.platform ?? null,
309
+ caption: ev.caption ?? null };
309
310
  }
310
311
  }
311
312
  // screenshotAscii pushes both the PNG (above) AND the raw ANSI
@@ -416,7 +417,8 @@
416
417
  card.className = "image-card";
417
418
  const dt = new Date(img.ts).toLocaleTimeString();
418
419
  const platBadge = img.platform ? `<span class="plat">${escapeHtml(img.platform)}</span> ` : "";
419
- card.innerHTML = `<div class="meta"><span>${platBadge}${escapeHtml(img.tool)}</span><span>${dt}</span></div>`;
420
+ const label = img.caption ? `${img.tool}${img.caption}` : img.tool;
421
+ card.innerHTML = `<div class="meta"><span>${platBadge}${escapeHtml(label)}</span><span>${dt}</span></div>`;
420
422
  const el = document.createElement("img");
421
423
  el.src = `data:${img.mimeType};base64,${img.base64}`;
422
424
  el.alt = img.tool;
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Idempotent per server instance — installs once, repeats are no-ops.
7
7
 
8
- import { observer, extractImages, summarizeForLog } from "./bus.js";
8
+ import { observer, extractImages, summarizeForLog, pushObserverFrame } from "./bus.js";
9
9
  import { getHostOrNull } from "../mcp/state.js";
10
10
 
11
11
  const INSTALLED = Symbol.for("romdev.observer-installed");
@@ -54,6 +54,7 @@ export function installObserverMiddleware(server, sessionKey) {
54
54
  const platform = sessionPlatform(sessionKey); // which console this call drives
55
55
  let event;
56
56
  let frameProvider = null; // deferred framebuffer thunk (encoded async below)
57
+ let frameCaption = null; // optional human label for the call_frame event
57
58
  if (thrown) {
58
59
  event = {
59
60
  type: "call",
@@ -98,6 +99,10 @@ export function installObserverMiddleware(server, sessionKey) {
98
99
  frameProvider = result._observerFrameProvider;
99
100
  delete result._observerFrameProvider;
100
101
  }
102
+ if (result && typeof result === "object" && typeof result._observerFrameCaption === "string") {
103
+ frameCaption = result._observerFrameCaption;
104
+ delete result._observerFrameCaption;
105
+ }
101
106
  const inlineImages = extractImages(result);
102
107
  const images = inlineImages.length > 0 ? inlineImages : sidebandImages;
103
108
  const resultSummary = summarizeForLog(result);
@@ -121,20 +126,18 @@ export function installObserverMiddleware(server, sessionKey) {
121
126
  // tool response on observer delivery.
122
127
  try { observer.push(event); } catch { /* never let observer kill the tool */ }
123
128
 
124
- // Deferred frame: encode + push the PNG AFTER the agent's response goes
125
- // out, so the (expensive) rasterize never delays the tool. setImmediate
126
- // yields the response first; a separate `call_frame` event carries the
127
- // image for the human's livestream. Best-effort never throws into the
128
- // tool path. (Only breakpoint/watch tools set a provider.)
129
+ // Deferred frame: encoded + pushed AFTER the agent's response goes out,
130
+ // throttled to one per 2s PER (session, tool) with a trailing-edge
131
+ // emit (bus.js pushObserverFrame) frame-step loops can't flood the
132
+ // stream, distinct tools never throttle each other, and the last frame
133
+ // of a burst always lands. Best-effort never throws into the tool
134
+ // path.
129
135
  if (frameProvider) {
130
- setImmediate(() => {
131
- try {
132
- const img = frameProvider();
133
- if (img) {
134
- observer.push({ type: "call_frame", sessionKey, platform, ts: startedAt, tool: name, images: [img] });
135
- }
136
- } catch { /* livestream is best-effort; never affects the agent */ }
137
- });
136
+ pushObserverFrame({
137
+ sessionKey, tool: name, ts: startedAt, platform,
138
+ resolvePlatform: () => sessionPlatform(sessionKey),
139
+ ...(frameCaption ? { caption: frameCaption } : {}),
140
+ }, frameProvider);
138
141
  }
139
142
 
140
143
  if (thrown) throw thrown;