c64bridge 1.0.6 → 1.1.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 (136) hide show
  1. package/.github/skills/assembly-program/SKILL.md +37 -0
  2. package/.github/skills/basic-program/SKILL.md +40 -0
  3. package/.github/skills/cross-platform-demo/SKILL.md +32 -0
  4. package/.github/skills/drive-manager/SKILL.md +33 -0
  5. package/.github/skills/graphics-demo/SKILL.md +30 -0
  6. package/.github/skills/hello-world/SKILL.md +36 -0
  7. package/.github/skills/memory-debug/SKILL.md +32 -0
  8. package/.github/skills/pr-converge/SKILL.md +33 -0
  9. package/.github/skills/printer-job/SKILL.md +30 -0
  10. package/.github/skills/sid-music/SKILL.md +35 -0
  11. package/.github/skills/software-launch/SKILL.md +31 -0
  12. package/.github/skills/stream-control/SKILL.md +29 -0
  13. package/.github/skills/system-control/SKILL.md +32 -0
  14. package/AGENTS.md +28 -7
  15. package/README.md +70 -15
  16. package/data/assembly/assembly-spec.md +2 -0
  17. package/dist/c64Client.js +238 -60
  18. package/dist/chargen.js +0 -4
  19. package/dist/config.js +53 -28
  20. package/dist/device.js +92 -37
  21. package/dist/diagnostics.js +66 -5
  22. package/dist/hostPaths.js +128 -0
  23. package/dist/logger.js +3 -2
  24. package/dist/loggingHttpClient.js +71 -2
  25. package/dist/mcp-server.js +416 -291
  26. package/dist/numericArg.js +55 -0
  27. package/dist/petscii.js +13 -6
  28. package/dist/platform.js +12 -3
  29. package/dist/prompts/registry.js +6 -1
  30. package/dist/redact.js +166 -0
  31. package/dist/tools/access.js +66 -0
  32. package/dist/tools/debug.js +6 -0
  33. package/dist/tools/developer.js +77 -36
  34. package/dist/tools/errors.js +54 -3
  35. package/dist/tools/input.js +25 -8
  36. package/dist/tools/machineControl.js +113 -21
  37. package/dist/tools/memory.js +135 -30
  38. package/dist/tools/meta/artifacts.js +5 -5
  39. package/dist/tools/meta/background.js +216 -54
  40. package/dist/tools/meta/compilation.js +6 -5
  41. package/dist/tools/meta/config.js +20 -10
  42. package/dist/tools/meta/configInventory.js +25 -2
  43. package/dist/tools/meta/diagnostics.js +2 -1
  44. package/dist/tools/meta/filesystem.js +16 -14
  45. package/dist/tools/meta/graphics.js +48 -20
  46. package/dist/tools/meta/memory.js +45 -19
  47. package/dist/tools/meta/program.js +10 -7
  48. package/dist/tools/meta/screen.js +116 -25
  49. package/dist/tools/pauseGuard.js +23 -0
  50. package/dist/tools/pollValidator.js +53 -8
  51. package/dist/tools/programRunners.js +62 -18
  52. package/dist/tools/registry/batch.js +17 -11
  53. package/dist/tools/registry/config.js +9 -2
  54. package/dist/tools/registry/disk.js +12 -2
  55. package/dist/tools/registry/drive.js +3 -0
  56. package/dist/tools/registry/extract.js +3 -0
  57. package/dist/tools/registry/graphics.js +59 -6
  58. package/dist/tools/registry/index.js +17 -1
  59. package/dist/tools/registry/memory.js +11 -1
  60. package/dist/tools/registry/platform.js +4 -1
  61. package/dist/tools/registry/printer.js +2 -1
  62. package/dist/tools/registry/program.js +5 -1
  63. package/dist/tools/registry/rag.js +1 -0
  64. package/dist/tools/registry/sound.js +12 -1
  65. package/dist/tools/registry/stream.js +2 -1
  66. package/dist/tools/registry/system.js +8 -1
  67. package/dist/tools/schema.js +7 -7
  68. package/dist/tools/streaming.js +34 -4
  69. package/dist/tools/translation/assembler.js +247 -24
  70. package/dist/tools/translation/basicDetokenizer.js +80 -0
  71. package/dist/tools/translation/basicTokenizer.js +8 -3
  72. package/dist/tools/translation/disassembler.js +14 -0
  73. package/dist/tools/types.js +258 -7
  74. package/dist/tools/vice.js +5 -16
  75. package/dist/vicBitmap.js +1 -1
  76. package/dist/vice/mockServer.js +3 -0
  77. package/dist/vice/process.js +19 -0
  78. package/dist/vice/resourcePolicy.js +36 -0
  79. package/dist/vice/viceClient.js +22 -5
  80. package/doc/vice/support-matrix.md +17 -11
  81. package/mcp/schemas/c64_batch.schema.json +2 -1
  82. package/mcp/schemas/c64_config.schema.json +33 -466
  83. package/mcp/schemas/c64_debug.schema.json +3 -602
  84. package/mcp/schemas/c64_disk.schema.json +17 -344
  85. package/mcp/schemas/c64_drive.schema.json +2 -157
  86. package/mcp/schemas/c64_extract.schema.json +10 -364
  87. package/mcp/schemas/c64_graphics.schema.json +19 -426
  88. package/mcp/schemas/c64_input.schema.json +5 -318
  89. package/mcp/schemas/c64_memory.schema.json +23 -491
  90. package/mcp/schemas/c64_printer.schema.json +3 -258
  91. package/mcp/schemas/c64_program.schema.json +16 -491
  92. package/mcp/schemas/c64_rag.schema.json +1 -81
  93. package/mcp/schemas/c64_select_backend.schema.json +2 -36
  94. package/mcp/schemas/c64_sound.schema.json +20 -822
  95. package/mcp/schemas/c64_stream.schema.json +4 -75
  96. package/mcp/schemas/c64_system.schema.json +28 -348
  97. package/mcp/schemas/c64_vice.schema.json +2 -71
  98. package/mcp/server.json +1 -1
  99. package/mcp/tools.json +1990 -6900
  100. package/mcp.json +21 -1
  101. package/package.json +19 -8
  102. package/scripts/mockC64Server.mjs +44 -0
  103. package/scripts/run-coverage.mjs +27 -1
  104. package/scripts/smoke-run.sh +3 -2
  105. package/scripts/start.mjs +23 -1
  106. package/server.json +53 -3
  107. package/dist/.tsbuildinfo +0 -1
  108. package/doc/agent-state-spec.md +0 -243
  109. package/doc/plans/bun/baseline-metrics.md +0 -16
  110. package/doc/plans/bun/migration-checklist.md +0 -55
  111. package/doc/plans/bun/migration.md +0 -83
  112. package/doc/plans/improve-knowledge/notes.md +0 -43
  113. package/doc/plans/improve-knowledge/plan.md +0 -121
  114. package/doc/plans/improve-knowledge/rollout.md +0 -110
  115. package/doc/plans/meta-tools/README.md +0 -110
  116. package/doc/plans/meta-tools/analysis.md +0 -272
  117. package/doc/plans/meta-tools/catalog.md +0 -435
  118. package/doc/plans/meta-tools/notes.md +0 -192
  119. package/doc/plans/meta-tools/rollout.md +0 -368
  120. package/doc/plans/prompts/README.md +0 -96
  121. package/doc/plans/prompts/user-prompt-ideas.md +0 -147
  122. package/doc/plans/tool-consolidation/notes.md +0 -28
  123. package/doc/plans/tool-consolidation/plan.md +0 -169
  124. package/doc/plans/tool-consolidation/rollout.md +0 -104
  125. package/doc/plans/vice-backend-switch/plan.md +0 -486
  126. package/doc/plans/vice-backend-switch-2/plan.md +0 -48
  127. package/doc/plans/vice-support/notes.md +0 -63
  128. package/doc/plans/vice-support/plan.md +0 -88
  129. package/doc/plans/vice-support/rollout.md +0 -99
  130. package/doc/reviews/audit.md +0 -143
  131. package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
  132. package/doc/reviews/hardening/01-fable/prompt.md +0 -98
  133. package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
  134. package/doc/reviews/hardening/01-fable/review.md +0 -1234
  135. package/doc/reviews/vice-support-review.md +0 -185
  136. package/scripts/compare-rest/rest-compare-2025-11-01T00-25-21-830Z.json +0 -2513
@@ -14,7 +14,7 @@ const findPathsByNameArgsSchema = objectSchema({
14
14
  root: optionalSchema(stringSchema({ description: "Root path to search (host)" }), "/"),
15
15
  nameContains: stringSchema({ description: "Substring to match", minLength: 1 }),
16
16
  extensions: optionalSchema(arraySchema(stringSchema({ description: "Extension filter without dot", minLength: 1 }))),
17
- maxResults: optionalSchema(numberSchema({ description: "Maximum results", integer: true, minimum: 1, default: 50 }), 50),
17
+ maxResults: optionalSchema(numberSchema({ description: "Maximum results", integer: true, minimum: 1, maximum: 1000, default: 100 }), 100),
18
18
  caseInsensitive: optionalSchema(booleanSchema({ description: "Case-insensitive name match", default: true }), true),
19
19
  },
20
20
  required: ["nameContains"],
@@ -321,12 +321,10 @@ function extensionKey(path) {
321
321
  const raw = extractExtension(path);
322
322
  return { raw, normalised: raw.length > 0 ? raw : "(none)" };
323
323
  }
324
- function buildSearchPattern(root, extension) {
325
- const trimmed = root === "/" ? "/" : root.replace(/\/+$/, "");
326
- if (trimmed === "/") {
327
- return extension ? `/**/*.${extension}` : `/**/*`;
328
- }
329
- return extension ? `${trimmed}/**/*.${extension}` : `${trimmed}/**/*`;
324
+ function buildSearchPattern(root, extension, nameContains) {
325
+ const trimmed = root.replace(/\/+$/, "");
326
+ const name = nameContains ? `*${nameContains}*` : "*";
327
+ return extension ? `${trimmed}/**/${name}.${extension}` : `${trimmed}/**/${name}`;
330
328
  }
331
329
  export const tools = [
332
330
  {
@@ -342,8 +340,9 @@ export const tools = [
342
340
  const needle = parsed.caseInsensitive ? parsed.nameContains.toLowerCase() : parsed.nameContains;
343
341
  const exts = (parsed.extensions ?? []);
344
342
  const patterns = exts.length > 0
345
- ? exts.map((e) => `${root}/**/*${parsed.nameContains}*.${e}`)
346
- : [`${root}/**/*${parsed.nameContains}*`];
343
+ ? exts.map((e) => buildSearchPattern(root, e, parsed.nameContains))
344
+ : [buildSearchPattern(root, undefined, parsed.nameContains)];
345
+ const limit = parsed.maxResults ?? 100;
347
346
  const seen = new Set();
348
347
  const results = [];
349
348
  for (const pattern of patterns) {
@@ -356,7 +355,7 @@ export const tools = [
356
355
  if (name.includes(needle) && !seen.has(p)) {
357
356
  seen.add(p);
358
357
  results.push(p);
359
- if (results.length >= (parsed.maxResults ?? 50))
358
+ if (results.length >= (limit + 1))
360
359
  break;
361
360
  }
362
361
  }
@@ -369,15 +368,18 @@ export const tools = [
369
368
  if (name.includes(needle) && !seen.has(p)) {
370
369
  seen.add(p);
371
370
  results.push(p);
372
- if (results.length >= (parsed.maxResults ?? 50))
371
+ if (results.length >= (limit + 1))
373
372
  break;
374
373
  }
375
374
  }
376
375
  }
377
- if (results.length >= (parsed.maxResults ?? 50))
376
+ if (results.length >= (limit + 1))
378
377
  break;
379
378
  }
380
- return jsonResult({ root, pattern: parsed.nameContains, results }, { success: true, count: results.length });
379
+ const truncated = results.length > limit;
380
+ if (truncated)
381
+ results.length = limit;
382
+ return jsonResult({ root, pattern: parsed.nameContains, results, truncated }, { success: true, count: results.length, truncated });
381
383
  }
382
384
  catch (error) {
383
385
  if (error instanceof ToolError)
@@ -530,7 +532,7 @@ export const tools = [
530
532
  const seen = new Set();
531
533
  const candidates = [];
532
534
  for (const ext of extensions) {
533
- const pattern = `${root}/**/*${needle}*.${ext}`;
535
+ const pattern = buildSearchPattern(root, ext, needle);
534
536
  try {
535
537
  const info = await ctx.client.filesInfo(pattern);
536
538
  const paths = extractPaths(info);
@@ -3,7 +3,9 @@ import { jsonResult } from "../responses.js";
3
3
  import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
4
  import { normalizeErrorDetails } from "./util.js";
5
5
  import { promises as fs } from "node:fs";
6
- import { dirname, resolve as resolvePath } from "node:path";
6
+ import { dirname } from "node:path";
7
+ import { resolveHostChildPath, resolveHostPath } from "../../hostPaths.js";
8
+ import { acquireOwnedPause, resumeWarning } from "../pauseGuard.js";
7
9
  function supportsMachinePause(ctx) {
8
10
  const platformId = ctx.platform?.id ?? ctx.client?.type;
9
11
  return platformId !== "vice";
@@ -214,14 +216,14 @@ export const tools = [
214
216
  throw new ToolValidationError("Stride must be at least 63 bytes to avoid overlapping sprites", { path: "$.stride" });
215
217
  }
216
218
  const canPause = pauseDuringRead && supportsMachinePause(ctx);
217
- const pauseResume = canPause
218
- ? await ctx.client.pause()
219
- : { success: true };
220
- if (canPause && !pauseResume.success) {
221
- throw new ToolExecutionError("Pause failed before sprite scan", { details: normalizeErrorDetails(pauseResume.details) });
219
+ const owned = await acquireOwnedPause(ctx.client, canPause);
220
+ const pinned = owned.facade;
221
+ if (canPause && !owned.result.success) {
222
+ throw new ToolExecutionError("Pause failed before sprite scan", { details: normalizeErrorDetails(owned.result.details) });
222
223
  }
224
+ let released = false;
223
225
  try {
224
- const read = await ctx.client.readMemory(`$${formatAddressHex(startAddress)}`, String(length));
226
+ const read = await ctx.client.readMemory(`$${formatAddressHex(startAddress)}`, String(length), pinned);
225
227
  if (!read.success || typeof read.data !== "string") {
226
228
  throw new ToolExecutionError("C64 backend reported failure while reading memory", { details: normalizeErrorDetails(read.details) });
227
229
  }
@@ -251,16 +253,25 @@ export const tools = [
251
253
  }
252
254
  let outputFiles = [];
253
255
  if (parsed.outputDir && sprites.length > 0) {
254
- const resolvedDir = resolvePath(String(parsed.outputDir));
256
+ const resolvedDir = resolveHostPath(String(parsed.outputDir), { purpose: "write" });
255
257
  await fs.mkdir(resolvedDir, { recursive: true });
256
258
  outputFiles = await Promise.all(sprites.map(async (sprite) => {
257
- const filePath = resolvePath(resolvedDir, `sprite_${sprite.address.replace("$", "").toLowerCase()}.spr`);
259
+ const filePath = resolveHostChildPath(resolvedDir, `sprite_${sprite.address.replace("$", "").toLowerCase()}.spr`);
258
260
  await fs.mkdir(dirname(filePath), { recursive: true });
259
261
  await fs.writeFile(filePath, Buffer.from(hexClean(sprite.bytesHex), "hex"));
260
262
  sprite.filePath = filePath;
261
263
  return { path: filePath, address: sprite.address };
262
264
  }));
263
265
  }
266
+ let warnings;
267
+ if (canPause) {
268
+ released = true;
269
+ const result = await owned.release();
270
+ if (!result.success) {
271
+ warnings = [resumeWarning(result.details)];
272
+ ctx.logger.warn("C64 resume reported failure after sprite scan", { details: result.details });
273
+ }
274
+ }
264
275
  return jsonResult({
265
276
  scanned: {
266
277
  address: `$${formatAddressHex(startAddress)}`,
@@ -271,11 +282,14 @@ export const tools = [
271
282
  },
272
283
  sprites,
273
284
  outputFiles,
285
+ ...(warnings ? { warnings } : {}),
274
286
  }, { success: true });
275
287
  }
276
288
  finally {
277
- if (canPause) {
278
- await ctx.client.resume();
289
+ if (canPause && !released) {
290
+ const result = await owned.release();
291
+ if (!result.success)
292
+ ctx.logger.warn("C64 resume reported failure after sprite scan", { details: result.details });
279
293
  }
280
294
  }
281
295
  }
@@ -333,18 +347,18 @@ export const tools = [
333
347
  scanLocations.push(...commonLocations);
334
348
  }
335
349
  const canPause = pauseDuringRead && supportsMachinePause(ctx);
336
- const pauseResume = canPause
337
- ? await ctx.client.pause()
338
- : { success: true };
339
- if (canPause && !pauseResume.success) {
340
- throw new ToolExecutionError("Pause failed before charset scan", { details: normalizeErrorDetails(pauseResume.details) });
350
+ const owned = await acquireOwnedPause(ctx.client, canPause);
351
+ const pinned = owned.facade;
352
+ if (canPause && !owned.result.success) {
353
+ throw new ToolExecutionError("Pause failed before charset scan", { details: normalizeErrorDetails(owned.result.details) });
341
354
  }
355
+ let released = false;
342
356
  try {
343
357
  const candidates = [];
344
358
  for (const startAddress of scanLocations) {
345
359
  if (startAddress + charsetSize - 1 > 0xFFFF)
346
360
  continue;
347
- const read = await ctx.client.readMemory(`$${formatAddressHex(startAddress)}`, String(charsetSize));
361
+ const read = await ctx.client.readMemory(`$${formatAddressHex(startAddress)}`, String(charsetSize), pinned);
348
362
  if (!read.success || typeof read.data !== "string") {
349
363
  continue; // Skip this location if read fails
350
364
  }
@@ -357,11 +371,22 @@ export const tools = [
357
371
  candidates.push({ ...analysis, bytes });
358
372
  }
359
373
  }
374
+ // Resume as soon as the reads are done, so a failure can be reported in the result.
375
+ let warnings;
376
+ if (canPause) {
377
+ released = true;
378
+ const result = await owned.release();
379
+ if (!result.success) {
380
+ warnings = [resumeWarning(result.details)];
381
+ ctx.logger.warn("C64 resume reported failure after charset scan", { details: result.details });
382
+ }
383
+ }
360
384
  if (candidates.length === 0) {
361
385
  return jsonResult({
362
386
  found: false,
363
387
  scanned: scanLocations.length,
364
388
  message: "No valid charsets found matching criteria",
389
+ ...(warnings ? { warnings } : {}),
365
390
  }, { success: true });
366
391
  }
367
392
  // Sort by non-empty chars descending, then by entropy descending
@@ -374,7 +399,7 @@ export const tools = [
374
399
  const bestCandidate = candidates[0];
375
400
  let outputFile = null;
376
401
  if (parsed.outputPath && bestCandidate.bytes) {
377
- const resolvedPath = resolvePath(String(parsed.outputPath));
402
+ const resolvedPath = resolveHostPath(String(parsed.outputPath), { purpose: "write" });
378
403
  await fs.mkdir(dirname(resolvedPath), { recursive: true });
379
404
  await fs.writeFile(resolvedPath, Buffer.from(bestCandidate.bytes));
380
405
  outputFile = { path: resolvedPath, address: bestCandidate.address };
@@ -386,11 +411,14 @@ export const tools = [
386
411
  candidates: candidates.length,
387
412
  outputFile,
388
413
  paused: canPause,
414
+ ...(warnings ? { warnings } : {}),
389
415
  }, { success: true });
390
416
  }
391
417
  finally {
392
- if (canPause) {
393
- await ctx.client.resume();
418
+ if (canPause && !released) {
419
+ const result = await owned.release();
420
+ if (!result.success)
421
+ ctx.logger.warn("C64 resume reported failure after charset scan", { details: result.details });
394
422
  }
395
423
  }
396
424
  }
@@ -2,10 +2,12 @@ import { objectSchema, stringSchema, numberSchema, optionalSchema, booleanSchema
2
2
  import { jsonResult } from "../responses.js";
3
3
  import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
4
  import { promises as fs } from "node:fs";
5
- import { dirname, resolve as resolvePath } from "node:path";
5
+ import { dirname } from "node:path";
6
+ import { resolveHostPath } from "../../hostPaths.js";
6
7
  import { createHash } from "node:crypto";
7
8
  import { normalizeErrorDetails } from "./util.js";
8
9
  import { stripHexByteSeparators } from "../hex.js";
10
+ import { acquireOwnedPause, resumeWarning } from "../pauseGuard.js";
9
11
  function supportsMachinePause(ctx) {
10
12
  const platformId = ctx.platform?.id ?? ctx.client?.type;
11
13
  return platformId !== "vice";
@@ -94,16 +96,17 @@ export const tools = [
94
96
  const readLen = Math.max(writeBytes.length, verifyLen);
95
97
  const canPause = supportsMachinePause(ctx);
96
98
  let paused = false;
99
+ const owned = await acquireOwnedPause(ctx.client, canPause);
100
+ const pinned = owned.facade;
97
101
  if (canPause) {
98
- const pauseResult = await ctx.client.pause();
99
- if (!pauseResult.success) {
100
- throw new ToolExecutionError("C64 backend reported failure while pausing", { details: normalizeErrorDetails(pauseResult.details) });
102
+ if (!owned.result.success) {
103
+ throw new ToolExecutionError("C64 backend reported failure while pausing", { details: normalizeErrorDetails(owned.result.details) });
101
104
  }
102
105
  paused = true;
103
106
  }
104
107
  let preReadHex;
105
108
  try {
106
- const pre = await ctx.client.readMemory(parsed.address, String(Math.max(1, readLen)));
109
+ const pre = await ctx.client.readMemory(parsed.address, String(Math.max(1, readLen)), pinned);
107
110
  if (!pre.success) {
108
111
  throw new ToolExecutionError("C64 backend reported failure while reading memory", { details: normalizeErrorDetails(pre.details) });
109
112
  }
@@ -123,11 +126,11 @@ export const tools = [
123
126
  throw new ToolExecutionError("Verification failed before write", { details: { mismatches: errors, address: parsed.address } });
124
127
  }
125
128
  }
126
- const write = await ctx.client.writeMemory(parsed.address, bytesToHex(writeBytes));
129
+ const write = await ctx.client.writeMemory(parsed.address, bytesToHex(writeBytes), pinned);
127
130
  if (!write.success) {
128
131
  throw new ToolExecutionError("C64 backend reported failure while writing memory", { details: normalizeErrorDetails(write.details) });
129
132
  }
130
- const post = await ctx.client.readMemory(parsed.address, String(Math.max(1, writeBytes.length)));
133
+ const post = await ctx.client.readMemory(parsed.address, String(Math.max(1, writeBytes.length)), pinned);
131
134
  if (!post.success) {
132
135
  throw new ToolExecutionError("C64 backend reported failure while reading back memory", { details: normalizeErrorDetails(post.details) });
133
136
  }
@@ -145,17 +148,29 @@ export const tools = [
145
148
  if (diffs.length > 0) {
146
149
  throw new ToolExecutionError("Post-write verification failed", { details: { address: parsed.address, diffs } });
147
150
  }
151
+ let warnings;
152
+ if (paused) {
153
+ paused = false; // released here, so the finally block must not release again
154
+ const released = await owned.release();
155
+ if (!released.success) {
156
+ warnings = [resumeWarning(released.details)];
157
+ ctx.logger.warn("C64 resume reported failure after verified write", { details: released.details });
158
+ }
159
+ }
148
160
  return jsonResult({
149
161
  address: parsed.address,
150
162
  wrote: bytesToHex(writeBytes),
151
163
  preRead: preReadHex,
152
164
  postRead: post.data ?? "",
153
165
  paused,
166
+ ...(warnings ? { warnings } : {}),
154
167
  }, { success: true });
155
168
  }
156
169
  finally {
157
170
  if (paused) {
158
- await ctx.client.resume();
171
+ const released = await owned.release();
172
+ if (!released.success)
173
+ ctx.logger.warn("C64 resume reported failure after verified write", { details: released.details });
159
174
  }
160
175
  }
161
176
  }
@@ -179,18 +194,18 @@ export const tools = [
179
194
  const length = parsed.length;
180
195
  const chunk = Math.min(Math.max(1, parsed.chunkSize ?? 512), 4096);
181
196
  const pause = parsed.pauseDuringRead !== false;
182
- const outputPath = resolvePath(String(parsed.outputPath));
197
+ const outputPath = resolveHostPath(String(parsed.outputPath), { purpose: "write" });
183
198
  const outDir = dirname(outputPath);
184
199
  await fs.mkdir(outDir, { recursive: true });
185
200
  const canPause = pause && supportsMachinePause(ctx);
186
- if (canPause) {
187
- const res = await ctx.client.pause();
188
- if (!res.success) {
189
- throw new ToolExecutionError("Pause failed before dump", { details: normalizeErrorDetails(res.details) });
190
- }
201
+ const owned = await acquireOwnedPause(ctx.client, canPause);
202
+ const pinned = owned.facade;
203
+ if (canPause && !owned.result.success) {
204
+ throw new ToolExecutionError("Pause failed before dump", { details: normalizeErrorDetails(owned.result.details) });
191
205
  }
192
206
  const buf = Buffer.allocUnsafe(length);
193
207
  let offset = 0;
208
+ let released = false;
194
209
  try {
195
210
  while (offset < length) {
196
211
  const remaining = length - offset;
@@ -205,7 +220,7 @@ export const tools = [
205
220
  while (!success && attempts <= (parsed.retries ?? 1)) {
206
221
  attempts += 1;
207
222
  try {
208
- const r = await ctx.client.readMemory(`$${formatAddressHex(addr)}`, String(take));
223
+ const r = await ctx.client.readMemory(`$${formatAddressHex(addr)}`, String(take), pinned);
209
224
  if (!r.success || typeof r.data !== "string") {
210
225
  throw new ToolExecutionError("Firmware returned failure for chunk", { details: normalizeErrorDetails(r.details) });
211
226
  }
@@ -243,12 +258,23 @@ export const tools = [
243
258
  paused: canPause,
244
259
  createdAt: new Date().toISOString(),
245
260
  };
246
- await fs.writeFile(`${outputPath}.json`, JSON.stringify(manifest, null, 2), "utf8");
247
- return jsonResult({ manifest }, { success: true });
261
+ await fs.writeFile(resolveHostPath(`${outputPath}.json`, { purpose: "write" }), JSON.stringify(manifest, null, 2), "utf8");
262
+ let warnings;
263
+ if (canPause) {
264
+ released = true;
265
+ const result = await owned.release();
266
+ if (!result.success) {
267
+ warnings = [resumeWarning(result.details)];
268
+ ctx.logger.warn("C64 resume reported failure after dump", { details: result.details });
269
+ }
270
+ }
271
+ return jsonResult({ manifest, ...(warnings ? { warnings } : {}) }, { success: true });
248
272
  }
249
273
  finally {
250
- if (canPause) {
251
- await ctx.client.resume();
274
+ if (canPause && !released) {
275
+ const result = await owned.release();
276
+ if (!result.success)
277
+ ctx.logger.warn("C64 resume reported failure after dump", { details: result.details });
252
278
  }
253
279
  }
254
280
  }
@@ -5,6 +5,7 @@ import { measureSidSilence } from "../audio.js";
5
5
  import { getPlatformStatus } from "../../platform.js";
6
6
  import { promises as fs } from "node:fs";
7
7
  import { resolve as resolvePath, join as joinPath } from "node:path";
8
+ import { defaultHostOutputDir, resolveHostChildPath, resolveHostPath } from "../../hostPaths.js";
8
9
  import { sleep, formatTimestampSpec } from "./util.js";
9
10
  import { getTasksHomeDir } from "./background.js";
10
11
  import { Jimp } from "jimp";
@@ -325,7 +326,9 @@ export const tools = [
325
326
  const captureScreenshot = visibleViceFastPath ? false : parsed.captureScreenshot !== false;
326
327
  const restoreActiveBackend = parsed.restoreActiveBackend !== false;
327
328
  const outputPath = (captureScreenshot && requestedBackends.some((backend) => backend !== "u2")) || parsed.outputPath
328
- ? resolvePath(String(parsed.outputPath ?? joinPath(process.cwd(), "artifacts", "greetings", `run_${Date.now()}`)))
329
+ ? (parsed.outputPath
330
+ ? resolveHostPath(String(parsed.outputPath), { purpose: "write" })
331
+ : defaultHostOutputDir("artifacts", "greetings", `run_${Date.now()}`))
329
332
  : undefined;
330
333
  const timeoutMs = parsed.timeoutMs ?? 1500;
331
334
  const pollIntervalMs = parsed.pollIntervalMs ?? 100;
@@ -434,7 +437,7 @@ export const tools = [
434
437
  await timedStep("persist_screenshot", async () => {
435
438
  screenshotAnalysis = analyseCapturedFrame(frame);
436
439
  screenshotPath = outputPath
437
- ? resolvePath(joinPath(outputPath, `${backend}.png`))
440
+ ? resolveHostChildPath(outputPath, `${backend}.png`)
438
441
  : undefined;
439
442
  if (screenshotPath) {
440
443
  await writeCapturedFramePng(frame, screenshotPath);
@@ -514,7 +517,7 @@ export const tools = [
514
517
  ...(restoreError ? { restoreError } : {}),
515
518
  };
516
519
  if (outputPath) {
517
- const reportPath = resolvePath(joinPath(outputPath, "results.json"));
520
+ const reportPath = resolveHostChildPath(outputPath, "results.json");
518
521
  await fs.writeFile(reportPath, JSON.stringify(payload, null, 2), "utf8");
519
522
  payload.outputPath = outputPath;
520
523
  payload.reportPath = reportPath;
@@ -571,7 +574,7 @@ export const tools = [
571
574
  }
572
575
  // Prepare output directory
573
576
  const outputPath = parsed.outputPath
574
- ? resolvePath(String(parsed.outputPath))
577
+ ? resolveHostPath(String(parsed.outputPath), { purpose: "write" })
575
578
  : resolvePath(joinPath(getTasksHomeDir(), `shuffle_${Date.now()}`));
576
579
  await fs.mkdir(outputPath, { recursive: true });
577
580
  const results = [];
@@ -619,7 +622,7 @@ export const tools = [
619
622
  });
620
623
  }
621
624
  // Write log
622
- const logPath = resolvePath(joinPath(outputPath, "shuffle.json"));
625
+ const logPath = resolveHostChildPath(outputPath, "shuffle.json");
623
626
  await fs.writeFile(logPath, JSON.stringify({ programs: results, summary: { total: results.length, errors: results.filter(r => r.error).length } }, null, 2), "utf8");
624
627
  return jsonResult({ outputPath, programs: results.length, errors: results.filter(r => r.error).length, logPath }, { success: true });
625
628
  }
@@ -660,7 +663,7 @@ export const tools = [
660
663
  const durationMs = parsed.durationMs ?? 2000;
661
664
  const resetDelayMs = parsed.resetDelayMs ?? 100;
662
665
  const outputPath = parsed.outputPath
663
- ? resolvePath(String(parsed.outputPath))
666
+ ? resolveHostPath(String(parsed.outputPath), { purpose: "write" })
664
667
  : resolvePath(joinPath(getTasksHomeDir(), `batch_${Date.now()}`));
665
668
  await fs.mkdir(outputPath, { recursive: true });
666
669
  const results = [];
@@ -768,7 +771,7 @@ export const tools = [
768
771
  }
769
772
  }
770
773
  // Write junit-like report
771
- const reportPath = resolvePath(joinPath(outputPath, "results.json"));
774
+ const reportPath = resolveHostChildPath(outputPath, "results.json");
772
775
  const summary = {
773
776
  total: results.length,
774
777
  passed: results.filter(r => r.status === "pass").length,
@@ -1,18 +1,120 @@
1
1
  import { objectSchema, stringSchema, numberSchema, optionalSchema, booleanSchema } from "../schema.js";
2
2
  import { jsonResult } from "../responses.js";
3
- import { ToolError, ToolExecutionError, toolErrorResult, unknownErrorResult } from "../errors.js";
3
+ import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
4
  import { sleep } from "./util.js";
5
5
  function escapeRegex(s) {
6
6
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
7
  }
8
+ export const MAX_PATTERN_LENGTH = 200;
9
+ export const MAX_WAIT_TIMEOUT_MS = 120000;
10
+ export const MIN_POLL_INTERVAL_MS = 20;
11
+ /**
12
+ * Checks a user regular expression before it runs on the event loop. A pattern
13
+ * such as (a+)+$ takes exponential time on a screen of identical characters, so
14
+ * quantified groups that contain a quantifier are rejected, as are
15
+ * backreferences (also a source of super-linear matching).
16
+ */
17
+ export function assertSafeScreenPattern(pattern) {
18
+ const path = "$.pattern";
19
+ if (pattern.length > MAX_PATTERN_LENGTH) {
20
+ throw new ToolValidationError(`Pattern is ${pattern.length} characters; the limit is ${MAX_PATTERN_LENGTH}`, { path });
21
+ }
22
+ // One entry per open group: whether it contains a quantifier.
23
+ const groups = [];
24
+ let inClass = false;
25
+ let previousWasGroupWithQuantifier = false;
26
+ for (let index = 0; index < pattern.length; index += 1) {
27
+ const char = pattern[index];
28
+ const isQuantifier = !inClass && (char === "*" || char === "+" || char === "?" || (char === "{" && /^\{\d+(,\d*)?\}/.test(pattern.slice(index))));
29
+ if (char === "\\") {
30
+ const next = pattern[index + 1] ?? "";
31
+ if (!inClass && (/[1-9]/.test(next) || (next === "k" && pattern[index + 2] === "<"))) {
32
+ throw new ToolValidationError("Backreferences are not supported in screen patterns", { path });
33
+ }
34
+ index += 1;
35
+ previousWasGroupWithQuantifier = false;
36
+ continue;
37
+ }
38
+ if (inClass) {
39
+ if (char === "]")
40
+ inClass = false;
41
+ continue;
42
+ }
43
+ if (char === "[") {
44
+ inClass = true;
45
+ previousWasGroupWithQuantifier = false;
46
+ continue;
47
+ }
48
+ if (char === "(") {
49
+ groups.push(false);
50
+ previousWasGroupWithQuantifier = false;
51
+ continue;
52
+ }
53
+ if (char === ")") {
54
+ previousWasGroupWithQuantifier = groups.pop() ?? false;
55
+ continue;
56
+ }
57
+ if (isQuantifier) {
58
+ // "(?" opens a group modifier, and a "?" after another quantifier makes it lazy; neither is a repeat.
59
+ const opensModifier = char === "?" && pattern[index - 1] === "(";
60
+ const isLazyMarker = char === "?" && "*+?}".includes(pattern[index - 1] ?? "");
61
+ if (!opensModifier && !isLazyMarker) {
62
+ if (previousWasGroupWithQuantifier) {
63
+ throw new ToolValidationError("Nested quantifiers (a repeated group that itself contains a repeat) are not supported in screen patterns", { path });
64
+ }
65
+ if (groups.length > 0)
66
+ groups[groups.length - 1] = true;
67
+ }
68
+ previousWasGroupWithQuantifier = false;
69
+ continue;
70
+ }
71
+ previousWasGroupWithQuantifier = false;
72
+ }
73
+ }
74
+ export function compileScreenPattern(pattern, isRegex, caseInsensitive) {
75
+ const flags = caseInsensitive ? "i" : undefined;
76
+ if (!isRegex) {
77
+ return new RegExp(escapeRegex(pattern), flags);
78
+ }
79
+ assertSafeScreenPattern(pattern);
80
+ try {
81
+ return new RegExp(pattern, flags);
82
+ }
83
+ catch (error) {
84
+ throw new ToolValidationError(`Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`, { path: "$.pattern" });
85
+ }
86
+ }
87
+ /** Polls the screen until the pattern matches or the timeout elapses. Does not throw on timeout. */
88
+ export async function waitForScreenText(client, options) {
89
+ const timeoutMs = Math.min(options.timeoutMs ?? 3000, MAX_WAIT_TIMEOUT_MS);
90
+ const intervalMs = Math.max(options.intervalMs ?? 100, MIN_POLL_INTERVAL_MS);
91
+ const regex = compileScreenPattern(options.pattern, options.isRegex ?? false, options.caseInsensitive ?? true);
92
+ const start = Date.now();
93
+ let lastScreen;
94
+ while (Date.now() - start < timeoutMs) {
95
+ const screen = await client.readScreen();
96
+ lastScreen = screen;
97
+ const match = screen.match(regex);
98
+ if (match && match.index !== undefined) {
99
+ return {
100
+ matched: true,
101
+ match: { index: match.index, length: match[0]?.length ?? 0, text: match[0] ?? "" },
102
+ elapsedMs: Date.now() - start,
103
+ screen,
104
+ };
105
+ }
106
+ await sleep(intervalMs);
107
+ }
108
+ return { matched: false, elapsedMs: Date.now() - start, screen: lastScreen };
109
+ }
8
110
  const waitForScreenTextArgsSchema = objectSchema({
9
111
  description: "Poll screen until text or regex matches, or timeout elapses.",
10
112
  properties: {
11
- pattern: stringSchema({ description: "Substring or regex to find", minLength: 1 }),
113
+ pattern: stringSchema({ description: "Substring or regex to find (at most 200 characters; regex with nested quantifiers or backreferences is rejected)", minLength: 1, maxLength: 200 }),
12
114
  isRegex: optionalSchema(booleanSchema({ description: "Interpret pattern as regular expression", default: false }), false),
13
115
  caseInsensitive: optionalSchema(booleanSchema({ description: "Case-insensitive search", default: true }), true),
14
- timeoutMs: optionalSchema(numberSchema({ description: "Overall timeout in milliseconds", integer: true, minimum: 1, default: 3000 }), 3000),
15
- intervalMs: optionalSchema(numberSchema({ description: "Poll interval in milliseconds", integer: true, minimum: 1, default: 100 }), 100),
116
+ timeoutMs: optionalSchema(numberSchema({ description: "Overall timeout in milliseconds (1-120000)", integer: true, minimum: 1, maximum: 120000, default: 3000 }), 3000),
117
+ intervalMs: optionalSchema(numberSchema({ description: "Poll interval in milliseconds (at least 20)", integer: true, minimum: 20, default: 100 }), 100),
16
118
  },
17
119
  required: ["pattern"],
18
120
  additionalProperties: false,
@@ -28,28 +130,17 @@ export const tools = [
28
130
  async execute(args, ctx) {
29
131
  try {
30
132
  const parsed = waitForScreenTextArgsSchema.parse(args ?? {});
31
- const start = Date.now();
32
- const flags = parsed.caseInsensitive ? "i" : undefined;
33
- const regex = parsed.isRegex
34
- ? new RegExp(parsed.pattern, flags)
35
- : new RegExp(escapeRegex(parsed.pattern), flags);
36
- while (Date.now() - start < (parsed.timeoutMs ?? 3000)) {
37
- const screen = await ctx.client.readScreen();
38
- const match = screen.match(regex);
39
- if (match && match.index !== undefined) {
40
- return jsonResult({
41
- matched: true,
42
- match: {
43
- index: match.index,
44
- length: match[0]?.length ?? 0,
45
- text: match[0] ?? "",
46
- },
47
- elapsedMs: Date.now() - start,
48
- }, { success: true });
49
- }
50
- await sleep(Math.max(1, parsed.intervalMs ?? 100));
133
+ const result = await waitForScreenText(ctx.client, parsed);
134
+ if (result.matched) {
135
+ return jsonResult({
136
+ matched: true,
137
+ match: result.match,
138
+ elapsedMs: result.elapsedMs,
139
+ }, { success: true });
51
140
  }
52
- throw new ToolExecutionError("Timeout waiting for screen text", { details: { pattern: parsed.pattern, timeoutMs: parsed.timeoutMs } });
141
+ throw new ToolExecutionError("Timeout waiting for screen text", {
142
+ details: { pattern: parsed.pattern, timeoutMs: parsed.timeoutMs, elapsedMs: result.elapsedMs, screen: result.screen ?? null },
143
+ });
53
144
  }
54
145
  catch (error) {
55
146
  if (error instanceof ToolError)
@@ -0,0 +1,23 @@
1
+ export async function acquireOwnedPause(client, enabled = true) {
2
+ const c = client;
3
+ const facade = typeof c.pinFacade === "function" ? await c.pinFacade() : undefined;
4
+ if (!enabled)
5
+ return { facade, result: { success: true }, release: async () => ({ success: true }) };
6
+ const result = typeof c.acquirePause === "function" ? await c.acquirePause(facade) : await c.pause(facade);
7
+ return {
8
+ facade,
9
+ result,
10
+ async release() {
11
+ try {
12
+ return typeof c.releasePause === "function" ? await c.releasePause(facade) : await c.resume(facade);
13
+ }
14
+ catch (error) {
15
+ return { success: false, details: { message: error instanceof Error ? error.message : String(error) } };
16
+ }
17
+ },
18
+ };
19
+ }
20
+ export function resumeWarning(details) {
21
+ const text = details && typeof details === "object" && "message" in details ? String(details.message) : "";
22
+ return `The machine could not be resumed after the operation${text ? ` (${text})` : ""}; call c64_system with op 'resume'.`;
23
+ }