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,12 +1,37 @@
1
+ // Background task management meta tools
2
+ import { assertAccess, taskOperationEffect } from "../access.js";
1
3
  import { objectSchema, stringSchema, numberSchema, optionalSchema } from "../schema.js";
2
4
  import { jsonResult } from "../responses.js";
3
5
  import { ToolError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
6
  import { promises as fs } from "node:fs";
5
- import { dirname, resolve as resolvePath, join as joinPath } from "node:path";
7
+ import { dirname, resolve as resolvePath, join as joinPath, sep } from "node:path";
6
8
  import os from "node:os";
7
9
  import { addMilliseconds } from "date-fns";
10
+ import { screenCodesToAscii } from "../../petscii.js";
11
+ import { loggerFor } from "../../logger.js";
8
12
  import { formatTimestampSpec, parseTimestampSpec } from "./util.js";
9
13
  const TASKS = new Map();
14
+ const logger = loggerFor("background");
15
+ export const MIN_INTERVAL_MS = 100;
16
+ export const MAX_RUNNING_TASKS = 16;
17
+ export const MAX_ITERATIONS_LIMIT = 100000;
18
+ const MAX_LOG_BYTES = 256 * 1024;
19
+ // Operations runOperation implements, including the *_memory aliases.
20
+ const SUPPORTED_OPERATIONS = ["read", "write", "read_screen", "menu_button", "read_memory", "write_memory"];
21
+ const MAX_STORED_RESULT_CHARS = 4096;
22
+ function summariseResult(result) {
23
+ let text;
24
+ try {
25
+ text = JSON.stringify(result) ?? "null";
26
+ }
27
+ catch {
28
+ return String(result).slice(0, MAX_STORED_RESULT_CHARS);
29
+ }
30
+ if (text.length <= MAX_STORED_RESULT_CHARS) {
31
+ return JSON.parse(text);
32
+ }
33
+ return { truncated: true, preview: text.slice(0, MAX_STORED_RESULT_CHARS) };
34
+ }
10
35
  function normaliseOperation(op) {
11
36
  switch (op) {
12
37
  case "read_memory":
@@ -32,8 +57,10 @@ function toPersistedTask(t) {
32
57
  updatedAt: formatTimestampSpec(t.updatedAt),
33
58
  stoppedAt: t.stoppedAt ? formatTimestampSpec(t.stoppedAt) : null,
34
59
  lastError: t.lastError ?? null,
60
+ lastResult: t.lastResult ?? null,
35
61
  nextRunAt: t.nextRunAt ? formatTimestampSpec(t.nextRunAt) : null,
36
62
  folder: t.folder,
63
+ backendId: t.backendId ?? null,
37
64
  };
38
65
  }
39
66
  function fromPersistedTask(p) {
@@ -51,8 +78,10 @@ function fromPersistedTask(p) {
51
78
  updatedAt: parseTimestampSpec(p.updatedAt) ?? new Date(),
52
79
  stoppedAt: parseTimestampSpec(p.stoppedAt ?? null),
53
80
  lastError: p.lastError ?? null,
81
+ lastResult: p.lastResult ?? null,
54
82
  nextRunAt: parseTimestampSpec(p.nextRunAt ?? null),
55
83
  folder: p.folder,
84
+ backendId: p.backendId ?? null,
56
85
  _timer: null,
57
86
  };
58
87
  }
@@ -73,36 +102,84 @@ function getBackgroundTaskFolderRelative(id) {
73
102
  return `tasks/background/${id}`;
74
103
  }
75
104
  function getBackgroundTaskFolderAbsolute(id) {
76
- return resolvePath(joinPath(getTasksHomeDir(), getBackgroundTaskFolderRelative(id)));
105
+ const base = resolvePath(joinPath(getTasksHomeDir(), "tasks", "background"));
106
+ const folder = resolvePath(joinPath(getTasksHomeDir(), getBackgroundTaskFolderRelative(id)));
107
+ // The task name is schema-restricted to one path segment; this also guards ids read from a state file.
108
+ if (!folder.startsWith(base + sep)) {
109
+ throw new ToolValidationError("Task folder must stay inside the background task directory", { path: "$.name" });
110
+ }
111
+ return folder;
77
112
  }
78
- let TASKS_LOADED = false;
79
- async function ensureTasksLoaded() {
80
- if (TASKS_LOADED)
81
- return;
82
- TASKS_LOADED = true;
113
+ let tasksLoadedPromise = null;
114
+ // Every caller awaits the same load, so a concurrent first call never sees an empty registry.
115
+ function ensureTasksLoaded() {
116
+ if (!tasksLoadedPromise) {
117
+ tasksLoadedPromise = loadTasks();
118
+ }
119
+ return tasksLoadedPromise;
120
+ }
121
+ async function moveCorruptStateAside(path, reason) {
122
+ const target = `${path}.corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
83
123
  try {
84
- const text = await fs.readFile(getTaskStateFilePath(), "utf8");
85
- const parsed = JSON.parse(text);
86
- if (parsed && Array.isArray(parsed.tasks)) {
87
- for (const pt of parsed.tasks) {
88
- const t = fromPersistedTask(pt);
89
- if (t.status === "running") {
90
- t.status = "stopped";
91
- t.stoppedAt = new Date();
92
- t.updatedAt = new Date();
93
- t.lastError = "Interrupted by MCP server restart; start the task again to resume monitoring.";
94
- t.nextRunAt = null;
95
- }
96
- TASKS.set(t.name, t);
97
- }
98
- }
124
+ await fs.rename(path, target);
125
+ logger.error(`Background task state file is unreadable (${reason}); moved it to ${target} and starting with an empty registry`);
126
+ }
127
+ catch (error) {
128
+ logger.error(`Background task state file is unreadable (${reason}) and could not be moved aside: ${error instanceof Error ? error.message : String(error)}`);
129
+ }
130
+ }
131
+ async function loadTasks() {
132
+ const path = getTaskStateFilePath();
133
+ let text;
134
+ try {
135
+ text = await fs.readFile(path, "utf8");
99
136
  }
100
137
  catch (error) {
101
138
  if (error?.code === "ENOENT") {
102
- const path = getTaskStateFilePath();
103
139
  await fs.mkdir(dirname(path), { recursive: true });
104
- await fs.writeFile(path, JSON.stringify({ tasks: [] }, null, 2), "utf8");
140
+ await writeFileAtomic(path, JSON.stringify({ tasks: [] }, null, 2));
141
+ }
142
+ else {
143
+ logger.error(`Unable to read background task state file ${path}: ${error instanceof Error ? error.message : String(error)}`);
144
+ }
145
+ return;
146
+ }
147
+ let parsed;
148
+ try {
149
+ parsed = JSON.parse(text);
150
+ }
151
+ catch (error) {
152
+ await moveCorruptStateAside(path, error instanceof Error ? error.message : String(error));
153
+ return;
154
+ }
155
+ if (!parsed || !Array.isArray(parsed.tasks)) {
156
+ await moveCorruptStateAside(path, "missing tasks array");
157
+ return;
158
+ }
159
+ for (const pt of parsed.tasks) {
160
+ const t = fromPersistedTask(pt);
161
+ if (t.status === "running") {
162
+ t.status = "stopped";
163
+ t.stoppedAt = new Date();
164
+ t.updatedAt = new Date();
165
+ t.lastError = "Interrupted by MCP server restart; start the task again to resume monitoring.";
166
+ t.nextRunAt = null;
105
167
  }
168
+ TASKS.set(t.name, t);
169
+ }
170
+ }
171
+ let tempFileCounter = 0;
172
+ // Write to a temporary file in the same directory, then rename, so readers never see a partial file.
173
+ async function writeFileAtomic(path, data) {
174
+ tempFileCounter += 1;
175
+ const temp = `${path}.${process.pid}.${tempFileCounter}.tmp`;
176
+ try {
177
+ await fs.writeFile(temp, data, "utf8");
178
+ await fs.rename(temp, path);
179
+ }
180
+ catch (error) {
181
+ await fs.rm(temp, { force: true }).catch(() => { });
182
+ throw error;
106
183
  }
107
184
  }
108
185
  async function writeTaskJson(task) {
@@ -111,7 +188,7 @@ async function writeTaskJson(task) {
111
188
  const pt = toPersistedTask(task);
112
189
  const data = { ...pt, resultPath: resultRelative };
113
190
  await fs.mkdir(folderAbs, { recursive: true });
114
- await fs.writeFile(resolvePath(folderAbs, "task.json"), JSON.stringify(data, null, 2), "utf8");
191
+ await writeFileAtomic(resolvePath(folderAbs, "task.json"), JSON.stringify(data, null, 2));
115
192
  }
116
193
  async function ensureResultAndLog(task) {
117
194
  const folderAbs = getBackgroundTaskFolderAbsolute(task.id);
@@ -130,7 +207,7 @@ async function ensureResultAndLog(task) {
130
207
  status: task.status,
131
208
  iterations: task.iterations,
132
209
  };
133
- await fs.writeFile(resultPath, JSON.stringify(initial, null, 2), "utf8");
210
+ await writeFileAtomic(resultPath, JSON.stringify(initial, null, 2));
134
211
  }
135
212
  try {
136
213
  await fs.access(logPath);
@@ -143,40 +220,74 @@ async function appendTaskLog(task, message) {
143
220
  const logPath = resolvePath(getBackgroundTaskFolderAbsolute(task.id), "log.txt");
144
221
  const ts = formatTimestampSpec(task.updatedAt ?? task.startedAt ?? new Date());
145
222
  await fs.appendFile(logPath, `[${ts}] ${message}\n`, "utf8");
223
+ await capTaskLog(logPath);
146
224
  }
147
- async function persistTasks() {
225
+ // Keep only the last MAX_LOG_BYTES of log.txt once it grows beyond that.
226
+ async function capTaskLog(logPath) {
227
+ const stats = await fs.stat(logPath);
228
+ if (stats.size <= MAX_LOG_BYTES)
229
+ return;
230
+ const handle = await fs.open(logPath, "r");
148
231
  try {
149
- const path = getTaskStateFilePath();
150
- await fs.mkdir(dirname(path), { recursive: true });
151
- const data = Array.from(TASKS.values()).map((t) => toPersistedTask(t));
152
- await fs.writeFile(path, JSON.stringify({ tasks: data }, null, 2), "utf8");
153
- // Keep per-task files up to date
154
- for (const t of TASKS.values()) {
155
- await writeTaskJson(t);
156
- await ensureResultAndLog(t);
232
+ const buffer = Buffer.alloc(MAX_LOG_BYTES);
233
+ const { bytesRead } = await handle.read(buffer, 0, MAX_LOG_BYTES, stats.size - MAX_LOG_BYTES);
234
+ let kept = buffer.subarray(0, bytesRead);
235
+ const firstNewline = kept.indexOf(0x0a);
236
+ if (firstNewline >= 0 && firstNewline < kept.length - 1) {
237
+ kept = kept.subarray(firstNewline + 1); // drop the partial first line
157
238
  }
239
+ await writeFileAtomic(logPath, kept.toString("utf8"));
158
240
  }
159
- catch {
160
- // ignore
241
+ finally {
242
+ await handle.close();
161
243
  }
162
244
  }
163
- function runOperation(op, args, ctx) {
164
- switch (normaliseOperation(op)) {
245
+ // Persistence is serialised so two writers never rename over each other.
246
+ let persistChain = Promise.resolve();
247
+ function persistTasks(changedTasks = []) {
248
+ const run = persistChain.then(async () => {
249
+ try {
250
+ const path = getTaskStateFilePath();
251
+ await fs.mkdir(dirname(path), { recursive: true });
252
+ const data = Array.from(TASKS.values()).map((t) => toPersistedTask(t));
253
+ await writeFileAtomic(path, JSON.stringify({ tasks: data }, null, 2));
254
+ // Only the changed tasks get their per-task files rewritten.
255
+ for (const t of changedTasks) {
256
+ await writeTaskJson(t);
257
+ await ensureResultAndLog(t);
258
+ }
259
+ }
260
+ catch (error) {
261
+ logger.warn(`Unable to persist background task state: ${error instanceof Error ? error.message : String(error)}`);
262
+ }
263
+ });
264
+ persistChain = run;
265
+ return run;
266
+ }
267
+ function runOperation(task, ctx) {
268
+ const args = task.args;
269
+ const facade = task._facade;
270
+ switch (normaliseOperation(task.operation)) {
165
271
  case "read": {
166
272
  const address = String(args.address ?? "$0400");
167
273
  const length = String(args.length ?? "16");
168
- return ctx.client.readMemory(address, length);
274
+ return ctx.client.readMemory(address, length, facade);
169
275
  }
170
276
  case "write": {
171
277
  const address = String(args.address ?? "$0400");
172
278
  const bytes = String(args.bytes ?? "$00");
173
- return ctx.client.writeMemory(address, bytes);
279
+ return ctx.client.writeMemory(address, bytes, facade);
174
280
  }
175
281
  case "read_screen": {
282
+ if (facade) {
283
+ return ctx.client
284
+ .readMemoryRaw(0x0400, 0x03e8, facade)
285
+ .then((bytes) => screenCodesToAscii(bytes, { columns: 40, rows: 25 }));
286
+ }
176
287
  return ctx.client.readScreen();
177
288
  }
178
289
  case "menu_button": {
179
- return ctx.client.menuButton();
290
+ return facade ? facade.menuButton() : ctx.client.menuButton();
180
291
  }
181
292
  default:
182
293
  return Promise.resolve({ success: true });
@@ -191,33 +302,41 @@ function scheduleNextRun(task, ctx) {
191
302
  if (task.status !== "running")
192
303
  return;
193
304
  try {
194
- const result = await runOperation(task.operation, task.args, ctx);
305
+ const result = await runOperation(task, ctx);
306
+ // A stop that arrived while the operation was in flight wins: never turn it into completed/error.
307
+ if (task.status !== "running")
308
+ return;
195
309
  if (result && typeof result === "object" && "success" in result && result.success === false) {
196
310
  throw new Error(`Operation returned failure: ${JSON.stringify(result.details ?? result)}`);
197
311
  }
198
312
  task.iterations += 1;
313
+ task.lastResult = summariseResult(result);
199
314
  task.updatedAt = new Date();
200
315
  if (task.maxIterations && task.iterations >= task.maxIterations) {
201
316
  task.status = "completed";
202
317
  task.stoppedAt = new Date();
203
318
  task._timer = null;
204
319
  await appendTaskLog(task, `completed iterations=${task.iterations}`);
205
- await persistTasks();
320
+ await persistTasks([task]);
206
321
  return;
207
322
  }
208
323
  await appendTaskLog(task, `iteration=${task.iterations}`);
209
- await persistTasks();
324
+ await persistTasks([task]);
210
325
  scheduleNextRun(task, ctx);
211
326
  }
212
327
  catch (err) {
328
+ if (task.status !== "running")
329
+ return;
213
330
  task.status = "error";
214
331
  task.lastError = err instanceof Error ? err.message : String(err);
215
332
  task.stoppedAt = new Date();
216
333
  task._timer = null;
217
334
  await appendTaskLog(task, `error: ${task.lastError}`);
218
- await persistTasks();
335
+ await persistTasks([task]);
219
336
  }
220
337
  }, delay);
338
+ // A pending task timer must never keep the process alive on its own.
339
+ task._timer.unref?.();
221
340
  }
222
341
  function stopTask(task) {
223
342
  if (task._timer) {
@@ -227,6 +346,30 @@ function stopTask(task) {
227
346
  task.status = task.status === "completed" ? task.status : "stopped";
228
347
  task.stoppedAt = new Date();
229
348
  }
349
+ /** Stops every running task (used at process shutdown) and persists the state. */
350
+ export async function stopAllBackgroundTasks() {
351
+ const stopped = [];
352
+ for (const task of TASKS.values()) {
353
+ if (task.status === "running") {
354
+ stopTask(task);
355
+ stopped.push(task);
356
+ }
357
+ }
358
+ await persistTasks(stopped);
359
+ }
360
+ function validateTaskOperation(operation, args) {
361
+ if (!SUPPORTED_OPERATIONS.includes(operation)) {
362
+ throw new ToolValidationError(`Unsupported background task operation '${operation}'. Allowed operations: ${SUPPORTED_OPERATIONS.join(", ")}.`, { path: "$.operation" });
363
+ }
364
+ if (normaliseOperation(operation) === "write") {
365
+ for (const key of ["address", "bytes"]) {
366
+ const value = args[key];
367
+ if (value === undefined || value === null || String(value).trim() === "") {
368
+ throw new ToolValidationError(`Background task operation 'write' requires arguments.${key}.`, { path: `$.arguments.${key}` });
369
+ }
370
+ }
371
+ }
372
+ }
230
373
  const noArgsSchema = objectSchema({ description: "No arguments", properties: {}, additionalProperties: false });
231
374
  const startBackgroundTaskArgsSchema = objectSchema({
232
375
  description: "Start a named background task that invokes a simple operation at fixed intervals.",
@@ -237,10 +380,10 @@ const startBackgroundTaskArgsSchema = objectSchema({
237
380
  minLength: 1,
238
381
  pattern: /^[A-Za-z0-9_][A-Za-z0-9_. -]*$/,
239
382
  }),
240
- operation: stringSchema({ description: "Operation name (e.g., read, read_screen)", minLength: 1 }),
383
+ operation: stringSchema({ description: "Operation name: read, write, read_screen or menu_button (read_memory and write_memory are aliases). write requires arguments.address and arguments.bytes.", minLength: 1 }),
241
384
  arguments: optionalSchema(objectSchema({ description: "Operation-specific arguments", properties: {}, additionalProperties: true }), {}),
242
- intervalMs: optionalSchema(numberSchema({ description: "Interval milliseconds", integer: true, minimum: 1, default: 1000 }), 1000),
243
- maxIterations: optionalSchema(numberSchema({ description: "Maximum number of iterations (omit for indefinite)", integer: true, minimum: 1 }), undefined),
385
+ intervalMs: optionalSchema(numberSchema({ description: `Interval milliseconds (minimum ${MIN_INTERVAL_MS})`, integer: true, minimum: MIN_INTERVAL_MS, default: 1000 }), 1000),
386
+ maxIterations: optionalSchema(numberSchema({ description: `Maximum number of iterations (1 to ${MAX_ITERATIONS_LIMIT}; omit for indefinite)`, integer: true, minimum: 1, maximum: MAX_ITERATIONS_LIMIT }), undefined),
244
387
  },
245
388
  required: ["name", "operation"],
246
389
  additionalProperties: false,
@@ -264,11 +407,18 @@ export const tools = [
264
407
  try {
265
408
  await ensureTasksLoaded();
266
409
  const parsed = startBackgroundTaskArgsSchema.parse(args ?? {});
410
+ validateTaskOperation(parsed.operation, parsed.arguments ?? {});
411
+ // The effect of the task operation is checked once, at start; a running task is not re-checked.
412
+ assertAccess("c64_system", "start_task", taskOperationEffect(parsed.operation));
267
413
  const now = new Date();
268
414
  const existing = TASKS.get(parsed.name);
269
415
  if (existing && existing.status === "running") {
270
416
  throw new ToolValidationError("Task with this name is already running", { path: "$.name" });
271
417
  }
418
+ const runningCount = Array.from(TASKS.values()).filter((t) => t.status === "running").length;
419
+ if (runningCount >= MAX_RUNNING_TASKS) {
420
+ throw new ToolValidationError(`Too many running background tasks (limit ${MAX_RUNNING_TASKS}); stop one first.`, { path: "$.name" });
421
+ }
272
422
  // Determine next global counter by scanning existing registry entries
273
423
  const allTasks = Array.from(TASKS.values());
274
424
  const currentMax = allTasks.reduce((max, t) => {
@@ -278,6 +428,9 @@ export const tools = [
278
428
  }, 0);
279
429
  const newCounter = String(currentMax + 1).padStart(4, "0");
280
430
  const newId = `${newCounter}_${parsed.name}`;
431
+ // Pin the facade now: a later c64_select_backend must not retarget a running task.
432
+ const pinnedFacade = ctx.client.pinFacade ? await ctx.client.pinFacade() : undefined;
433
+ const backendId = pinnedFacade?.type ?? (ctx.client.getActiveBackendType ? await ctx.client.getActiveBackendType() : null);
281
434
  const task = {
282
435
  id: existing && existing.status !== "running" ? existing.id : newId,
283
436
  name: parsed.name,
@@ -294,7 +447,9 @@ export const tools = [
294
447
  lastError: null,
295
448
  nextRunAt: null,
296
449
  folder: getBackgroundTaskFolderRelative(existing && existing.status !== "running" ? existing.id : newId),
450
+ backendId,
297
451
  _timer: null,
452
+ _facade: pinnedFacade,
298
453
  };
299
454
  TASKS.set(task.name, task);
300
455
  // Ensure per-task structure and seed files
@@ -303,8 +458,8 @@ export const tools = [
303
458
  await ensureResultAndLog(task);
304
459
  await appendTaskLog(task, "started");
305
460
  scheduleNextRun(task, ctx);
306
- await persistTasks();
307
- return jsonResult({ started: true, task: { id: task.id, name: task.name, operation: task.operation, intervalMs: task.intervalMs, maxIterations: task.maxIterations ?? null, folder: task.folder } }, { success: true });
461
+ await persistTasks([task]);
462
+ return jsonResult({ started: true, task: { id: task.id, name: task.name, operation: task.operation, backendId: task.backendId ?? null, intervalMs: task.intervalMs, maxIterations: task.maxIterations ?? null, folder: task.folder } }, { success: true });
308
463
  }
309
464
  catch (error) {
310
465
  if (error instanceof ToolError)
@@ -330,7 +485,7 @@ export const tools = [
330
485
  }
331
486
  stopTask(task);
332
487
  await appendTaskLog(task, "stopped");
333
- await persistTasks();
488
+ await persistTasks([task]);
334
489
  return jsonResult({ stopped: true, name: task.name, status: task.status }, { success: true });
335
490
  }
336
491
  catch (error) {
@@ -354,6 +509,7 @@ export const tools = [
354
509
  id: t.id,
355
510
  name: t.name,
356
511
  type: t.type,
512
+ backendId: t.backendId ?? null,
357
513
  status: t.status,
358
514
  iterations: t.iterations,
359
515
  intervalMs: t.intervalMs,
@@ -363,6 +519,7 @@ export const tools = [
363
519
  updatedAt: formatTimestampSpec(t.updatedAt),
364
520
  stoppedAt: t.stoppedAt ? formatTimestampSpec(t.stoppedAt) : null,
365
521
  lastError: t.lastError ?? null,
522
+ lastResult: t.lastResult ?? null,
366
523
  folder: t.folder,
367
524
  }));
368
525
  return jsonResult({ tasks }, { success: true, count: tasks.length });
@@ -384,12 +541,17 @@ export const tools = [
384
541
  try {
385
542
  await ensureTasksLoaded();
386
543
  stopAllBackgroundTasksArgsSchema.parse(args ?? {});
544
+ const stoppedTasks = [];
387
545
  for (const task of TASKS.values()) {
546
+ // Tasks that already finished or were stopped are left as they are.
547
+ if (task.status !== "running")
548
+ continue;
388
549
  stopTask(task);
550
+ stoppedTasks.push(task);
389
551
  await appendTaskLog(task, "stopped");
390
552
  }
391
- await persistTasks();
392
- return jsonResult({ stoppedAll: true, count: TASKS.size }, { success: true });
553
+ await persistTasks(stoppedTasks);
554
+ return jsonResult({ stoppedAll: true, count: stoppedTasks.length, totalTasks: TASKS.size }, { success: true });
393
555
  }
394
556
  catch (error) {
395
557
  if (error instanceof ToolError)
@@ -3,6 +3,7 @@ import { jsonResult } from "../responses.js";
3
3
  import { ToolError, ToolExecutionError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
4
  import { promises as fs } from "node:fs";
5
5
  import { resolve as resolvePath, join as joinPath } from "node:path";
6
+ import { resolveHostChildPath, resolveHostPath } from "../../hostPaths.js";
6
7
  import { sleep } from "./util.js";
7
8
  import { getTasksHomeDir } from "./background.js";
8
9
  const compileRunVerifyCycleArgsSchema = objectSchema({
@@ -46,7 +47,7 @@ export const tools = [
46
47
  const verifyAudio = parsed.verifyAudio ?? false;
47
48
  const durationMs = parsed.durationMs ?? 2000;
48
49
  const outputPath = parsed.outputPath
49
- ? resolvePath(String(parsed.outputPath))
50
+ ? resolveHostPath(String(parsed.outputPath), { purpose: "write" })
50
51
  : resolvePath(joinPath(getTasksHomeDir(), `cycle_${Date.now()}`));
51
52
  await fs.mkdir(outputPath, { recursive: true });
52
53
  const runId = `cycle_${Date.now()}`;
@@ -80,12 +81,12 @@ export const tools = [
80
81
  verificationMessage = verified ? "Screen verification passed" : `Screen does not contain "${verifyScreen}"`;
81
82
  }
82
83
  // Archive artifacts
83
- const sourcePath = resolvePath(joinPath(outputPath, `source.${sourceType}`));
84
+ const sourcePath = resolveHostChildPath(outputPath, `source.${sourceType}`);
84
85
  await fs.writeFile(sourcePath, source, "utf8");
85
- const screenPath = resolvePath(joinPath(outputPath, "screen.txt"));
86
+ const screenPath = resolveHostChildPath(outputPath, "screen.txt");
86
87
  const screen = await ctx.client.readScreen();
87
88
  await fs.writeFile(screenPath, screen, "utf8");
88
- const manifestPath = resolvePath(joinPath(outputPath, "manifest.json"));
89
+ const manifestPath = resolveHostChildPath(outputPath, "manifest.json");
89
90
  const manifest = {
90
91
  runId,
91
92
  sourceType,
@@ -104,7 +105,7 @@ export const tools = [
104
105
  }
105
106
  catch (e) {
106
107
  const error = e instanceof Error ? e.message : String(e);
107
- const manifestPath = resolvePath(joinPath(outputPath, "manifest.json"));
108
+ const manifestPath = resolveHostChildPath(outputPath, "manifest.json");
108
109
  const manifest = {
109
110
  runId,
110
111
  sourceType,
@@ -3,19 +3,31 @@ import { jsonResult } from "../responses.js";
3
3
  import { ToolError, ToolUnsupportedPlatformError, ToolExecutionError, ToolValidationError, toolErrorResult, unknownErrorResult } from "../errors.js";
4
4
  import { getPlatformStatus } from "../../platform.js";
5
5
  import { promises as fs } from "node:fs";
6
- import { dirname, resolve as resolvePath } from "node:path";
7
- import { captureConfigSnapshot, validateSnapshotCategories } from "./configInventory.js";
6
+ import { dirname } from "node:path";
7
+ import { resolveHostPath } from "../../hostPaths.js";
8
+ import { captureConfigSnapshot, stripRedactedSecrets, validateSnapshotCategories } from "./configInventory.js";
8
9
  import { normalizeErrorDetails } from "./util.js";
9
10
  const configSnapshotAndRestoreArgsSchema = objectSchema({
10
11
  description: "Snapshot or restore full device configuration. Snapshot writes versioned JSON.",
11
12
  properties: {
12
13
  action: stringSchema({ description: "One of: snapshot|restore|diff", enum: ["snapshot", "restore", "diff"] }),
13
14
  path: stringSchema({ description: "Snapshot file path", minLength: 1 }),
15
+ includeSecretValues: optionalSchema(booleanSchema({ description: "Snapshot and diff only: store credential values (network password, FTP/Telnet passwords, Wi-Fi keys) in plain text in the snapshot file. Default false: they are written as ***, and restore skips them.", default: false }), false),
14
16
  applyToFlash: optionalSchema(booleanSchema({ description: "Save configuration to flash after restore (C64U/U64 and U2 only)", default: false }), false),
15
17
  },
16
18
  required: ["action", "path"],
17
19
  additionalProperties: false,
18
20
  });
21
+ // JSON.parse errors can quote the start of the file, so only the path is reported.
22
+ async function readSnapshotFile(path) {
23
+ const text = await fs.readFile(path, "utf8");
24
+ try {
25
+ return JSON.parse(text);
26
+ }
27
+ catch {
28
+ throw new ToolValidationError(`Snapshot file is not valid JSON: ${path}`, { path: "$.path" });
29
+ }
30
+ }
19
31
  export const tools = [
20
32
  {
21
33
  name: "config_snapshot_and_restore",
@@ -31,13 +43,13 @@ export const tools = [
31
43
  if (action === "restore" && parsed.applyToFlash && platform === "vice") {
32
44
  throw new ToolUnsupportedPlatformError("config_save_to_flash", platform, ["c64u", "u2"]);
33
45
  }
34
- const path = resolvePath(String(parsed.path));
46
+ const path = resolveHostPath(String(parsed.path), { purpose: action === "snapshot" ? "write" : "read" });
35
47
  if (action === "snapshot") {
36
48
  await fs.mkdir(dirname(path), { recursive: true });
37
49
  const [version, info, snapshotData] = await Promise.all([
38
50
  ctx.client.version(),
39
51
  ctx.client.info(),
40
- captureConfigSnapshot(ctx.client),
52
+ captureConfigSnapshot(ctx.client, { includeSecretValues: parsed.includeSecretValues === true }),
41
53
  ]);
42
54
  const snapshot = {
43
55
  createdAt: new Date().toISOString(),
@@ -54,14 +66,13 @@ export const tools = [
54
66
  }, { success: true });
55
67
  }
56
68
  if (action === "restore") {
57
- const text = await fs.readFile(path, "utf8");
58
- const snapshot = JSON.parse(text);
69
+ const snapshot = await readSnapshotFile(path);
59
70
  if (!snapshot || typeof snapshot !== "object") {
60
71
  throw new ToolValidationError("Invalid snapshot file", { path: "$.path" });
61
72
  }
62
73
  let payload;
63
74
  try {
64
- payload = validateSnapshotCategories(snapshot.categories);
75
+ payload = stripRedactedSecrets(validateSnapshotCategories(snapshot.categories));
65
76
  }
66
77
  catch {
67
78
  throw new ToolValidationError("Invalid snapshot file", { path: "$.path" });
@@ -78,10 +89,9 @@ export const tools = [
78
89
  }
79
90
  return jsonResult({ restored: true, categories: Object.keys(payload).length }, { success: true });
80
91
  }
81
- const text = await fs.readFile(path, "utf8");
82
- const snapshot = JSON.parse(text);
92
+ const snapshot = await readSnapshotFile(path);
83
93
  const snapCats = validateSnapshotCategories(snapshot.categories ?? {});
84
- const current = (await captureConfigSnapshot(ctx.client)).categories;
94
+ const current = (await captureConfigSnapshot(ctx.client, { includeSecretValues: parsed.includeSecretValues === true })).categories;
85
95
  const diff = {};
86
96
  for (const [cat, snapVal] of Object.entries(snapCats)) {
87
97
  const curVal = current[cat];
@@ -1,3 +1,4 @@
1
+ import { isSensitiveConfigItem, redactConfigValue, REDACTED_PLACEHOLDER } from "../../redact.js";
1
2
  export function normalizeConfigInventory(details) {
2
3
  const categories = details?.categories;
3
4
  if (!Array.isArray(categories)) {
@@ -55,12 +56,15 @@ export function normalizeConfigInventory(details) {
55
56
  }
56
57
  return inventory;
57
58
  }
58
- export async function captureConfigSnapshot(client) {
59
+ export async function captureConfigSnapshot(client, options = {}) {
59
60
  const inventory = normalizeConfigInventory(await client.configsList());
60
61
  const categories = {};
61
62
  for (const entry of inventory) {
62
63
  try {
63
- const value = normalizeSnapshotValue(await client.configGet(entry.category, entry.item), entry);
64
+ const normalized = normalizeSnapshotValue(await client.configGet(entry.category, entry.item), entry);
65
+ const value = options.includeSecretValues
66
+ ? normalized
67
+ : redactConfigValue(normalized, entry.category, entry.item);
64
68
  setSnapshotValue(categories, entry, value);
65
69
  }
66
70
  catch (error) {
@@ -71,6 +75,25 @@ export async function captureConfigSnapshot(client) {
71
75
  }
72
76
  return { inventory, categories };
73
77
  }
78
+ /**
79
+ * Drops redacted credential placeholders from a snapshot payload before it is
80
+ * written back, so restoring a default snapshot never sets a password to `***`.
81
+ */
82
+ export function stripRedactedSecrets(categories) {
83
+ const strip = (category, value) => {
84
+ if (value === null || typeof value !== "object" || Array.isArray(value))
85
+ return value;
86
+ const kept = {};
87
+ for (const [key, entry] of Object.entries(value)) {
88
+ if (entry === REDACTED_PLACEHOLDER && isSensitiveConfigItem(category, key))
89
+ continue;
90
+ // Per-item snapshot entries keep the firmware wrapper: { category: { item: value } }.
91
+ kept[key] = strip(entry !== null && typeof entry === "object" && !Array.isArray(entry) ? key : category, entry);
92
+ }
93
+ return kept;
94
+ };
95
+ return Object.fromEntries(Object.entries(categories).map(([category, items]) => [category, strip(category, items)]));
96
+ }
74
97
  export function validateSnapshotCategories(value) {
75
98
  if (!value || typeof value !== "object" || Array.isArray(value)) {
76
99
  throw new Error("Snapshot categories payload must be an object");
@@ -74,7 +74,8 @@ export const tools = [
74
74
  version,
75
75
  info,
76
76
  };
77
- return jsonResult(report, { success: report.isHealthy });
77
+ const result = jsonResult(report, { success: report.isHealthy });
78
+ return report.isHealthy ? result : { ...result, isError: true };
78
79
  }
79
80
  catch (error) {
80
81
  if (error instanceof ToolError)