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
@@ -1,6 +1,7 @@
1
1
  import { getPlatformStatus, isPlatformSupported, setPlatform, } from "../platform.js";
2
2
  import { withDiagnosticSpan } from "../diagnostics.js";
3
3
  import { ToolUnsupportedPlatformError, ToolValidationError } from "./errors.js";
4
+ import { annotationsForEffects } from "./access.js";
4
5
  export const OPERATION_DISCRIMINATOR = "op";
5
6
  export const VERIFY_PROPERTY_NAME = "verify";
6
7
  export const VERIFY_PROPERTY_SCHEMA = Object.freeze({
@@ -35,6 +36,7 @@ export function discriminatedUnionSchema(options) {
35
36
  // so we merge all variant properties and use an enum for the discriminator.
36
37
  const opValues = [];
37
38
  const mergedProperties = {};
39
+ const propertyVariants = {};
38
40
  const opDescriptions = [];
39
41
  const operationMetadata = [];
40
42
  for (const variant of variants) {
@@ -64,11 +66,14 @@ export function discriminatedUnionSchema(options) {
64
66
  for (const [key, propSchema] of Object.entries(variantProps)) {
65
67
  if (key === discriminator)
66
68
  continue;
67
- if (!mergedProperties[key]) {
68
- mergedProperties[key] = propSchema;
69
- }
69
+ (propertyVariants[key] ?? (propertyVariants[key] = [])).push(propSchema);
70
70
  }
71
71
  }
72
+ // Variants may declare the same property with different limits. The flattened
73
+ // property must accept every variant's values; per-op validation stays strict.
74
+ for (const [key, schemas] of Object.entries(propertyVariants)) {
75
+ mergedProperties[key] = combinePropertySchemas(schemas);
76
+ }
72
77
  const opPropertySchema = opValues.length > 0
73
78
  ? { type: "string", enum: opValues, description: `Selects the operation.` }
74
79
  : { type: "string", description: `Selects the operation.` };
@@ -92,6 +97,170 @@ export function discriminatedUnionSchema(options) {
92
97
  };
93
98
  return schema;
94
99
  }
100
+ /**
101
+ * Returns the input schema as published on the wire. The per-operation schemas in
102
+ * "x-c64bridge-operations" duplicate the property information of the flattened
103
+ * schema and roughly halve its size, so they are omitted unless a client opts in
104
+ * with C64BRIDGE_VERBOSE_SCHEMA=1. The registry keeps them for documentation generators.
105
+ */
106
+ export function publishedInputSchema(schema, options = {}) {
107
+ const verbose = options.verbose ?? process.env.C64BRIDGE_VERBOSE_SCHEMA === "1";
108
+ if (!schema || verbose || !("x-c64bridge-operations" in schema)) {
109
+ return schema;
110
+ }
111
+ const { "x-c64bridge-operations": _operations, ...published } = schema;
112
+ return published;
113
+ }
114
+ const OP_LIMITS_NOTE = "Limits depend on op.";
115
+ const WIDENABLE_KEYS = new Set([
116
+ "type", "description", "enum", "const", "default", "minimum", "maximum", "minLength", "maxLength",
117
+ "minItems", "maxItems", "pattern", "format", "items", "examples",
118
+ ]);
119
+ function deepEqual(a, b) {
120
+ if (a === b)
121
+ return true;
122
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
123
+ return false;
124
+ if (Array.isArray(a) !== Array.isArray(b))
125
+ return false;
126
+ const aKeys = Object.keys(a);
127
+ const bKeys = Object.keys(b);
128
+ if (aKeys.length !== bKeys.length)
129
+ return false;
130
+ return aKeys.every((key) => deepEqual(a[key], b[key]));
131
+ }
132
+ function withoutDescription(schema) {
133
+ const { description: _description, ...rest } = schema;
134
+ return rest;
135
+ }
136
+ function typeSet(schema) {
137
+ if (schema.type === undefined) {
138
+ return undefined;
139
+ }
140
+ const types = Array.isArray(schema.type) ? [...schema.type] : [schema.type];
141
+ return types;
142
+ }
143
+ function enumValues(schema) {
144
+ if (schema.enum)
145
+ return schema.enum;
146
+ if ("const" in schema)
147
+ return [schema.const];
148
+ return undefined;
149
+ }
150
+ function smallest(a, b) {
151
+ return a === undefined || b === undefined ? undefined : Math.min(a, b);
152
+ }
153
+ function largest(a, b) {
154
+ return a === undefined || b === undefined ? undefined : Math.max(a, b);
155
+ }
156
+ /** Returns a schema accepting everything either input accepts, or undefined when that cannot be expressed safely. */
157
+ function widenPair(a, b) {
158
+ const aRecord = a;
159
+ const bRecord = b;
160
+ for (const key of new Set([...Object.keys(aRecord), ...Object.keys(bRecord)])) {
161
+ if (!WIDENABLE_KEYS.has(key) && !deepEqual(aRecord[key], bRecord[key])) {
162
+ return undefined;
163
+ }
164
+ }
165
+ const aTypes = typeSet(a);
166
+ const bTypes = typeSet(b);
167
+ let types;
168
+ if (aTypes && bTypes) {
169
+ types = Array.from(new Set([...aTypes, ...bTypes]));
170
+ if (types.includes("number")) {
171
+ types = types.filter((type) => type !== "integer");
172
+ }
173
+ }
174
+ // Structured types only widen when their element schema widens.
175
+ let items;
176
+ if (a.items !== undefined || b.items !== undefined) {
177
+ if (!a.items || !b.items || Array.isArray(a.items) || Array.isArray(b.items)) {
178
+ if (!deepEqual(a.items, b.items))
179
+ return undefined;
180
+ items = a.items;
181
+ }
182
+ else {
183
+ items = combinePropertySchemas([a.items, b.items], false);
184
+ }
185
+ }
186
+ const aEnum = enumValues(a);
187
+ const bEnum = enumValues(b);
188
+ const enumUnion = aEnum && bEnum
189
+ ? [...aEnum, ...bEnum.filter((value) => !aEnum.includes(value))]
190
+ : undefined;
191
+ const minimum = smallest(a.minimum, b.minimum);
192
+ const maximum = largest(a.maximum, b.maximum);
193
+ const minLength = smallest(a.minLength, b.minLength);
194
+ const maxLength = largest(a.maxLength, b.maxLength);
195
+ const minItems = smallest(a.minItems, b.minItems);
196
+ const maxItems = largest(a.maxItems, b.maxItems);
197
+ const result = {};
198
+ for (const [key, value] of Object.entries(a)) {
199
+ if (!WIDENABLE_KEYS.has(key))
200
+ result[key] = value;
201
+ }
202
+ if (types)
203
+ result.type = types.length === 1 ? types[0] : types;
204
+ if (a.description !== undefined || b.description !== undefined)
205
+ result.description = a.description ?? b.description;
206
+ if (enumUnion)
207
+ result.enum = enumUnion;
208
+ if (minimum !== undefined)
209
+ result.minimum = minimum;
210
+ if (maximum !== undefined)
211
+ result.maximum = maximum;
212
+ if (minLength !== undefined)
213
+ result.minLength = minLength;
214
+ if (maxLength !== undefined)
215
+ result.maxLength = maxLength;
216
+ if (minItems !== undefined)
217
+ result.minItems = minItems;
218
+ if (maxItems !== undefined)
219
+ result.maxItems = maxItems;
220
+ if (items)
221
+ result.items = items;
222
+ if (a.pattern !== undefined && a.pattern === b.pattern)
223
+ result.pattern = a.pattern;
224
+ if (a.format !== undefined && a.format === b.format)
225
+ result.format = a.format;
226
+ if (deepEqual(a.default, b.default) && a.default !== undefined)
227
+ result.default = a.default;
228
+ if (deepEqual(a.examples, b.examples) && a.examples !== undefined)
229
+ result.examples = a.examples;
230
+ return result;
231
+ }
232
+ /**
233
+ * Combines the schemas that different operations declare for one property into a
234
+ * single schema accepting all of their values. Identical schemas are returned untouched.
235
+ */
236
+ export function combinePropertySchemas(schemas, annotate = true) {
237
+ const first = schemas[0];
238
+ const distinct = [];
239
+ for (const schema of schemas) {
240
+ if (!distinct.some((existing) => deepEqual(withoutDescription(existing), withoutDescription(schema)))) {
241
+ distinct.push(schema);
242
+ }
243
+ }
244
+ if (distinct.length === 1) {
245
+ return first;
246
+ }
247
+ let merged = distinct[0];
248
+ for (const schema of distinct.slice(1)) {
249
+ merged = merged ? widenPair(merged, schema) : undefined;
250
+ }
251
+ const result = merged ?? {
252
+ anyOf: distinct.map(withoutDescription),
253
+ ...(first.description ? { description: first.description } : {}),
254
+ };
255
+ if (!annotate) {
256
+ return result;
257
+ }
258
+ const description = result.description;
259
+ if (description?.includes(OP_LIMITS_NOTE)) {
260
+ return result;
261
+ }
262
+ return { ...result, description: description ? `${description} ${OP_LIMITS_NOTE}` : OP_LIMITS_NOTE };
263
+ }
95
264
  export function createOperationDispatcher(toolName, handlers) {
96
265
  const allowed = Object.keys(handlers).sort();
97
266
  return async (args, ctx) => {
@@ -107,7 +276,7 @@ export function createOperationDispatcher(toolName, handlers) {
107
276
  // Own properties only: "constructor", "toString", etc. must not resolve to Object.prototype members.
108
277
  const handler = hasOwnKey(handlers, opKey) ? handlers[opKey] : undefined;
109
278
  if (!handler) {
110
- throw new ToolValidationError(`${toolName} does not support ${OPERATION_DISCRIMINATOR} "${opValue}"`, { path: `$.${OPERATION_DISCRIMINATOR}`, details: { allowed } });
279
+ throw new ToolValidationError(`${toolName} does not support ${OPERATION_DISCRIMINATOR} "${opValue}"`, { path: `$.${OPERATION_DISCRIMINATOR}`, details: { allowed, received: opValue } });
111
280
  }
112
281
  return handler(record, ctx);
113
282
  };
@@ -145,25 +314,40 @@ export function defineToolModule(config) {
145
314
  ...(prerequisites ? { prerequisites } : {}),
146
315
  ...(platforms ? { platforms } : {}),
147
316
  ...(tool.operationPlatforms ? { operationPlatforms: tool.operationPlatforms } : {}),
317
+ ...(tool.operationEffects ? { operationEffects: tool.operationEffects } : {}),
318
+ ...(tool.effect ? { effect: tool.effect } : {}),
148
319
  };
320
+ const declaredEffects = tool.operationEffects ? Object.values(tool.operationEffects) : tool.effect ? [tool.effect] : [];
149
321
  return {
150
322
  name: tool.name,
151
323
  description: tool.description,
152
- inputSchema: tool.inputSchema,
324
+ inputSchema: tagOperations(tool.inputSchema, tool.operationPlatforms, tool.operationEffects),
325
+ annotations: annotationsForEffects(declaredEffects),
153
326
  metadata,
154
327
  };
155
328
  });
156
329
  },
330
+ effectOf(name, args) {
331
+ const tool = toolMap.get(name);
332
+ return tool ? resolveToolEffect(tool, args) : undefined;
333
+ },
157
334
  async invoke(name, args, ctx) {
158
335
  const tool = toolMap.get(name);
159
336
  if (!tool) {
160
- throw new Error(`Unknown tool: ${name}`);
337
+ throw new ToolValidationError(`Unknown tool: ${name}`, {
338
+ path: "$.name",
339
+ details: { allowed: Array.from(toolMap.keys()).sort(), received: name },
340
+ });
161
341
  }
162
342
  const platforms = resolveSupportedPlatforms(tool, args, defaultPlatforms);
163
343
  const status = ctx.platform ?? getPlatformStatus();
164
344
  const setter = ctx.setPlatform ?? setPlatform;
165
345
  if (!isPlatformSupported(status.id, platforms)) {
166
- throw new ToolUnsupportedPlatformError(resolveUnsupportedToolName(name, tool, args), status.id, platforms);
346
+ throw new ToolUnsupportedPlatformError(resolveUnsupportedToolName(name, tool, args), status.id, platforms, {
347
+ toolName: name,
348
+ op: getSelectedOperation(args),
349
+ configured: configuredBackends(ctx),
350
+ });
167
351
  }
168
352
  const enrichedCtx = {
169
353
  ...ctx,
@@ -179,6 +363,73 @@ export function defineToolModule(config) {
179
363
  },
180
364
  };
181
365
  }
366
+ const ALL_PLATFORMS = ["c64u", "u2", "vice"];
367
+ /**
368
+ * Marks operations in the operation list of a grouped tool description: operations that are not
369
+ * available on every backend get their backend list and operations that are not read-only get
370
+ * their effect, for example "- unmount [c64u, u2] [write]: ...".
371
+ */
372
+ export function tagOperations(schema, operationPlatforms, operationEffects) {
373
+ if (!schema?.description || (!operationPlatforms && !operationEffects)) {
374
+ return schema;
375
+ }
376
+ let description = schema.description;
377
+ const ops = new Set([...Object.keys(operationPlatforms ?? {}), ...Object.keys(operationEffects ?? {})]);
378
+ for (const op of ops) {
379
+ const platforms = ownValue(operationPlatforms, op);
380
+ const effect = ownValue(operationEffects, op);
381
+ let tags = "";
382
+ if (platforms && platforms.length > 0 && !ALL_PLATFORMS.every((id) => platforms.includes(id))) {
383
+ tags += ` [${platforms.join(", ")}]`;
384
+ }
385
+ if (effect && effect !== "read") {
386
+ tags += ` [${effect}]`;
387
+ }
388
+ if (!tags) {
389
+ continue;
390
+ }
391
+ const marker = `\n- ${op}: `;
392
+ if (description.includes(marker)) {
393
+ description = description.replace(marker, `\n- ${op}${tags}: `);
394
+ }
395
+ }
396
+ return description === schema.description ? schema : { ...schema, description };
397
+ }
398
+ function resolveToolEffect(tool, args) {
399
+ const op = getSelectedOperation(args);
400
+ const overridden = tool.effectOf?.(op, args);
401
+ if (overridden) {
402
+ return overridden;
403
+ }
404
+ if (!tool.operationEffects) {
405
+ // Tools without operations; an undeclared effect fails closed.
406
+ return tool.effect ?? "destructive";
407
+ }
408
+ if (!op) {
409
+ return undefined;
410
+ }
411
+ const declared = ownValue(tool.operationEffects, op);
412
+ if (declared) {
413
+ return declared;
414
+ }
415
+ const opSchema = tool.inputSchema?.properties?.[OPERATION_DISCRIMINATOR];
416
+ const known = opSchema?.enum?.includes(op) === true;
417
+ // An operation of the schema without a declared effect fails closed; an unknown name is left
418
+ // to the dispatcher, which reports the validation error.
419
+ return known ? "destructive" : undefined;
420
+ }
421
+ function configuredBackends(ctx) {
422
+ const client = ctx.client;
423
+ if (typeof client?.getAvailableBackends !== "function") {
424
+ return undefined;
425
+ }
426
+ try {
427
+ return client.getAvailableBackends();
428
+ }
429
+ catch {
430
+ return undefined;
431
+ }
432
+ }
182
433
  function resolveSupportedPlatforms(tool, args, defaultPlatforms) {
183
434
  const basePlatforms = mergePlatforms(defaultPlatforms, tool.supportedPlatforms) ?? defaultPlatforms;
184
435
  const operation = getSelectedOperation(args);
@@ -2,17 +2,7 @@ import { createOperationDispatcher, defineToolModule, discriminatedUnionSchema,
2
2
  import { literalSchema, objectSchema, stringSchema, } from "./schema.js";
3
3
  import { jsonResult, textResult } from "./responses.js";
4
4
  import { ToolError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult, } from "./errors.js";
5
- const RESOURCE_NAME_PATTERN = /^[A-Z][A-Za-z0-9_.-]{0,63}$/;
6
- const SAFE_RESOURCE_PREFIXES = [
7
- "C64",
8
- "Machine",
9
- "Sid",
10
- "VIC",
11
- "VICII",
12
- "CIA",
13
- "Joy",
14
- "Sound",
15
- ];
5
+ import { RESOURCE_NAME_PATTERN, resourceNameProblem } from "../vice/resourcePolicy.js";
16
6
  const resourceValueSchema = {
17
7
  jsonSchema: {
18
8
  type: ["string", "integer"],
@@ -124,6 +114,7 @@ export const viceModuleGroup = defineToolModule({
124
114
  description: "VICE emulator operations available via the c64_vice tool.",
125
115
  variants: viceOperationSchemas.map((schema) => schema.jsonSchema),
126
116
  }),
117
+ operationEffects: { resource_get: "read", resource_set: "write" },
127
118
  tags: ["vice", "resource", "grouped"],
128
119
  examples: [
129
120
  {
@@ -148,10 +139,8 @@ function handleToolError(error) {
148
139
  return unknownErrorResult(error);
149
140
  }
150
141
  function ensureSafeResourceName(name, path) {
151
- if (!RESOURCE_NAME_PATTERN.test(name)) {
152
- throw new ToolValidationError("Resource names must start with an uppercase letter and use letters, numbers, dashes, underscores, or periods.", { path, details: { name } });
153
- }
154
- if (!SAFE_RESOURCE_PREFIXES.some((prefix) => name.startsWith(prefix))) {
155
- throw new ToolValidationError(`Resource name must start with one of: ${SAFE_RESOURCE_PREFIXES.join(", ")}`, { path, details: { name } });
142
+ const problem = resourceNameProblem(name);
143
+ if (problem) {
144
+ throw new ToolValidationError(problem.message, { path, details: { name } });
156
145
  }
157
146
  }
package/dist/vicBitmap.js CHANGED
@@ -6,7 +6,7 @@ const COLOR_RAM_BYTE_LENGTH = 1000;
6
6
  const COLOR_RAM_ADDRESS = 0xD800;
7
7
  const CHAR_ROM_SHADOW_OFFSET = 0x1000;
8
8
  const CHAR_ROM_SHADOW_LENGTH = 0x1000;
9
- const VIC_PALETTE = [
9
+ export const VIC_PALETTE = [
10
10
  [0x00, 0x00, 0x00],
11
11
  [0xFF, 0xFF, 0xFF],
12
12
  [0x81, 0x33, 0x38],
@@ -34,6 +34,8 @@ export class ViceMockServer {
34
34
  this.server = null;
35
35
  this.memory = new Uint8Array(0x10000);
36
36
  this.helloReady = false;
37
+ /** Test hook: the payload of every keyboard feed command received, in order. */
38
+ this.keyboardFeeds = [];
37
39
  this.heartbeat = null;
38
40
  this.checkpoints = new Map();
39
41
  this.nextCheckpointId = 1;
@@ -241,6 +243,7 @@ export class ViceMockServer {
241
243
  }
242
244
  case 0x72: { // keyboard feed
243
245
  const len = body[0] ?? 0;
246
+ this.keyboardFeeds.push(Buffer.from(body.subarray(1, 1 + len)));
244
247
  const text = body.subarray(1, 1 + len).toString("ascii").toUpperCase();
245
248
  if (text.includes("RUN")) {
246
249
  this.renderHello();
@@ -185,7 +185,26 @@ export async function terminateProcess(child, signal = "SIGTERM", timeoutMs = 10
185
185
  catch { }
186
186
  await waitForExit(child, timeoutMs);
187
187
  }
188
+ /**
189
+ * Rejects when something already listens on the monitor address. waitForPort
190
+ * succeeds against any listener, so without this check a foreign process on
191
+ * the port would be taken for the new emulator's monitor.
192
+ */
193
+ export async function assertMonitorPortFree(host, port) {
194
+ if (!Number.isInteger(port) || port <= 0)
195
+ return;
196
+ await new Promise((resolve, reject) => {
197
+ const probe = net.connect({ host, port });
198
+ probe.setTimeout(500, () => probe.destroy(new Error("timeout")));
199
+ probe.once("connect", () => {
200
+ probe.destroy();
201
+ reject(new Error(`Cannot start VICE: port ${port} on ${host} is already in use by another process. Stop it or configure a different vice.port.`));
202
+ });
203
+ probe.once("error", () => resolve()); // refused or unreachable: nothing is listening
204
+ });
205
+ }
188
206
  export async function startViceProcess(options) {
207
+ await assertMonitorPortFree(options.host, options.port);
189
208
  const debugEnabled = process.env.VICE_DEVICE_TEST_DEBUG === "1";
190
209
  // When headless=true, skip Xvfb entirely and rely on VICE's own -headless flag (VICE 3.5+).
191
210
  const useHeadless = options.headless === true || process.env.VICE_HEADLESS === "1";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Which VICE resources may be read or written through the MCP tools.
3
+ * Shared by c64_vice and by c64_config on the VICE backend, so neither path
4
+ * can reach a resource the other refuses (several VICE resources hold host
5
+ * file names).
6
+ */
7
+ export const RESOURCE_NAME_PATTERN = /^[A-Z][A-Za-z0-9_.-]{0,63}$/;
8
+ export const SAFE_RESOURCE_PREFIXES = [
9
+ "C64",
10
+ "Machine",
11
+ "Sid",
12
+ "VIC",
13
+ "VICII",
14
+ "CIA",
15
+ "Joy",
16
+ "Sound",
17
+ "Warp", // WarpMode: a plain on/off switch
18
+ ];
19
+ /** Resources that name host files or directories, even under a safe prefix (for example SoundRecordDeviceArg). */
20
+ const HOST_FILE_RESOURCE_PATTERN = /(File|Image|Dir|Directory|Path|Arg|Name)\d*$/;
21
+ /** Returns why a resource name is refused, or undefined when it is allowed. */
22
+ export function resourceNameProblem(name) {
23
+ if (!RESOURCE_NAME_PATTERN.test(name)) {
24
+ return {
25
+ kind: "pattern",
26
+ message: "Resource names must start with an uppercase letter and use letters, numbers, dashes, underscores, or periods.",
27
+ };
28
+ }
29
+ if (!SAFE_RESOURCE_PREFIXES.some((prefix) => name.startsWith(prefix))) {
30
+ return { kind: "prefix", message: `Resource name must start with one of: ${SAFE_RESOURCE_PREFIXES.join(", ")}` };
31
+ }
32
+ if (HOST_FILE_RESOURCE_PATTERN.test(name)) {
33
+ return { kind: "host_file", message: `Resource '${name}' refers to a host file or directory and cannot be accessed through this tool.` };
34
+ }
35
+ return undefined;
36
+ }
@@ -2,6 +2,8 @@
2
2
  * VICE Binary Monitor client with debugger, resource, and display helpers.
3
3
  */
4
4
  import net from "node:net";
5
+ /** How long a TCP connect to the monitor may take before it is abandoned. */
6
+ export const VICE_CONNECT_TIMEOUT_MS = 5000;
5
7
  export class ViceClient {
6
8
  constructor() {
7
9
  this.buffer = Buffer.alloc(0);
@@ -12,16 +14,27 @@ export class ViceClient {
12
14
  setUnsolicitedHandler(handler) {
13
15
  this.onUnsolicitedHandler = handler;
14
16
  }
15
- async connect(port, host = "127.0.0.1") {
16
- this.socket = net.connect({ host, port });
17
+ async connect(port, host = "127.0.0.1", options = {}) {
18
+ const timeoutMs = options.connectTimeoutMs ?? VICE_CONNECT_TIMEOUT_MS;
19
+ this.socket = options.socketFactory ? options.socketFactory(host, port) : net.connect({ host, port });
17
20
  this.closing = false;
18
21
  this.socket.setNoDelay(true);
19
22
  await new Promise((resolve, reject) => {
23
+ const timer = setTimeout(() => {
24
+ // A host that drops the SYN would otherwise block until the OS gives up.
25
+ this.socket.off("connect", onConnect);
26
+ this.socket.off("error", onError);
27
+ this.socket.on("error", () => { }); // destroy() may still emit an error
28
+ this.socket.destroy();
29
+ reject(new Error(`Timed out after ${timeoutMs} ms connecting to the VICE monitor at ${host}:${port}`));
30
+ }, timeoutMs);
20
31
  const onConnect = () => {
32
+ clearTimeout(timer);
21
33
  this.socket.off("error", onError);
22
34
  resolve();
23
35
  };
24
36
  const onError = (error) => {
37
+ clearTimeout(timer);
25
38
  this.socket.off("connect", onConnect);
26
39
  reject(error);
27
40
  };
@@ -212,10 +225,14 @@ export class ViceClient {
212
225
  }
213
226
  async keyboardFeed(text) {
214
227
  if (!text)
215
- return;
228
+ throw new Error("keyboardFeed requires non-empty text");
216
229
  const bytes = Buffer.from(text, "ascii");
217
- const body = Buffer.concat([Buffer.from([bytes.length & 0xff]), bytes]);
218
- await this.send(0x72, body);
230
+ // The command carries its length in one byte, so longer text goes out as
231
+ // consecutive commands, in order.
232
+ for (let offset = 0; offset < bytes.length; offset += 255) {
233
+ const chunk = bytes.subarray(offset, offset + 255);
234
+ await this.send(0x72, Buffer.concat([Buffer.from([chunk.length]), chunk]));
235
+ }
219
236
  }
220
237
  /** Set the VICE joyport's active-low control byte (BM command 0xA2). */
221
238
  async joyportSet(port, activeLowMask) {
@@ -21,15 +21,18 @@ This document is the source of truth for VICE support in the public MCP surface.
21
21
 
22
22
  | Tool | Operations |
23
23
  | --- | --- |
24
- | `c64_program` | `run_prg`, `upload_run_basic`, `upload_run_asm`, `batch_run` |
25
- | `c64_memory` | `read`, `read_screen`, `wait_for_text`, `write` |
26
- | `c64_system` | `reset`, `reboot`, `poweroff`, `start_task`, `stop_task`, `stop_all_tasks`, `list_tasks` |
27
- | `c64_graphics` | `render_petscii_art`, `render_bitmap`, `render_sprite`, `render_petscii_text` |
24
+ | `c64_program` | `load_prg`, `run_prg`, `upload_run_basic`, `upload_run_asm`, `batch_run`, `bundle_run`, `cross_platform_greeting` |
25
+ | `c64_memory` | `read`, `write`, `read_screen`, `wait_for_text`, `list_basic`, `disassemble`, `copy_memory`, `fill_memory`, `search_memory`, `compare_memory`, `save_memory` |
26
+ | `c64_system` | `reset`, `reboot`, `poweroff`, `power_cycle`, `start_task`, `stop_task`, `stop_all_tasks`, `list_tasks`, `performance_report` |
27
+ | `c64_graphics` | `capture_frame`, `render_petscii_art`, `render_bitmap`, `render_sprite`, `render_petscii_text`, `get_display_state` |
28
28
  | `c64_rag` | `basic`, `asm` |
29
- | `c64_disk` | `list_drives`, `mount`, `unmount` |
29
+ | `c64_disk` | `list_drives`, `mount` |
30
30
  | `c64_drive` | `reset`, `power_on`, `power_off`, `set_mode` |
31
31
  | `c64_config` | `list`, `get`, `set`, `batch_update`, `info`, `version`, `snapshot`, `restore`, `diff` |
32
- | `c64_sound` | `set_volume`, `reset`, `note_on`, `note_off`, `silence_all`, `generate`, `compile_play` |
32
+ | `c64_sound` | `set_volume`, `reset`, `note_on`, `note_off`, `silence_all`, `generate`, `compile_play`, `play_preset`, `pipeline`, `analyze`, `record_analyze` |
33
+ | `c64_input` | `write_text`, `key`, `joystick` |
34
+ | `c64_extract` | `sprites`, `charset`, `memory_dump`, `firmware_health` |
35
+ | `c64_select_backend` | `select` |
33
36
  | `c64_debug` | all operations |
34
37
  | `c64_vice` | all operations |
35
38
 
@@ -37,15 +40,18 @@ This document is the source of truth for VICE support in the public MCP surface.
37
40
 
38
41
  | Tool | Operations |
39
42
  | --- | --- |
40
- | `c64_program` | `load_prg`, `run_crt`, `bundle_run` |
41
- | `c64_disk` | `file_info`, `create_image`, `find_and_run` |
43
+ | `c64_program` | `run_crt` |
44
+ | `c64_disk` | `unmount`, `file_info`, `create_image`, `find_and_run`, `find_paths` |
42
45
  | `c64_drive` | `load_rom` |
43
- | `c64_sound` | `play_sid_file`, `play_mod_file`, `pipeline`, `analyze`, `record_analyze` |
46
+ | `c64_sound` | `capture_samples`, `play_sid_file`, `play_mod_file` |
44
47
  | `c64_config` | `load_flash`, `save_flash`, `reset_defaults`, `read_debugreg`, `write_debugreg`, `shuffle` |
45
- | `c64_system` | `pause`, `resume`, `menu` |
48
+ | `c64_system` | `pause`, `resume`, `menu`, `read_menu_screen` |
49
+ | `c64_input` | `keyboard`, `release_all`, `state` |
50
+ | `c64_extract` | `fs_stats` |
46
51
  | `c64_stream` | all operations |
47
52
  | `c64_printer` | all operations |
48
- | `c64_extract` | all operations |
53
+
54
+ The per-operation support is declared in `src/tools/registry/*.ts` (`operationPlatforms` and `supportedPlatforms`); the generated tables in `README.md` and `mcp/*.json` reflect it. If this file and the registry differ, the registry is correct.
49
55
 
50
56
  ## Notes
51
57
 
@@ -34,7 +34,8 @@
34
34
  ],
35
35
  "additionalProperties": false
36
36
  },
37
- "minItems": 1
37
+ "minItems": 1,
38
+ "maxItems": 64
38
39
  },
39
40
  "stopOnError": {
40
41
  "type": [