c64bridge 1.0.7 → 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.
- package/.github/skills/assembly-program/SKILL.md +37 -0
- package/.github/skills/basic-program/SKILL.md +40 -0
- package/.github/skills/cross-platform-demo/SKILL.md +32 -0
- package/.github/skills/drive-manager/SKILL.md +33 -0
- package/.github/skills/graphics-demo/SKILL.md +30 -0
- package/.github/skills/hello-world/SKILL.md +36 -0
- package/.github/skills/memory-debug/SKILL.md +32 -0
- package/.github/skills/pr-converge/SKILL.md +33 -0
- package/.github/skills/printer-job/SKILL.md +30 -0
- package/.github/skills/sid-music/SKILL.md +35 -0
- package/.github/skills/software-launch/SKILL.md +31 -0
- package/.github/skills/stream-control/SKILL.md +29 -0
- package/.github/skills/system-control/SKILL.md +32 -0
- package/AGENTS.md +28 -7
- package/README.md +69 -14
- package/data/assembly/assembly-spec.md +2 -0
- package/dist/c64Client.js +238 -60
- package/dist/config.js +48 -26
- package/dist/device.js +92 -37
- package/dist/diagnostics.js +62 -2
- package/dist/hostPaths.js +128 -0
- package/dist/loggingHttpClient.js +59 -1
- package/dist/mcp-server.js +409 -286
- package/dist/numericArg.js +55 -0
- package/dist/petscii.js +13 -1
- package/dist/platform.js +12 -3
- package/dist/prompts/registry.js +6 -1
- package/dist/tools/access.js +66 -0
- package/dist/tools/debug.js +6 -0
- package/dist/tools/developer.js +67 -34
- package/dist/tools/errors.js +54 -3
- package/dist/tools/input.js +23 -6
- package/dist/tools/machineControl.js +112 -20
- package/dist/tools/memory.js +135 -30
- package/dist/tools/meta/artifacts.js +5 -5
- package/dist/tools/meta/background.js +216 -54
- package/dist/tools/meta/compilation.js +6 -5
- package/dist/tools/meta/config.js +15 -6
- package/dist/tools/meta/filesystem.js +16 -14
- package/dist/tools/meta/graphics.js +48 -20
- package/dist/tools/meta/memory.js +45 -19
- package/dist/tools/meta/program.js +10 -7
- package/dist/tools/meta/screen.js +116 -25
- package/dist/tools/pauseGuard.js +23 -0
- package/dist/tools/pollValidator.js +53 -8
- package/dist/tools/programRunners.js +62 -18
- package/dist/tools/registry/batch.js +16 -4
- package/dist/tools/registry/config.js +9 -2
- package/dist/tools/registry/disk.js +12 -2
- package/dist/tools/registry/drive.js +3 -0
- package/dist/tools/registry/extract.js +3 -0
- package/dist/tools/registry/graphics.js +59 -6
- package/dist/tools/registry/index.js +17 -1
- package/dist/tools/registry/memory.js +11 -1
- package/dist/tools/registry/platform.js +4 -1
- package/dist/tools/registry/printer.js +2 -1
- package/dist/tools/registry/program.js +5 -1
- package/dist/tools/registry/rag.js +1 -0
- package/dist/tools/registry/sound.js +12 -1
- package/dist/tools/registry/stream.js +2 -1
- package/dist/tools/registry/system.js +8 -1
- package/dist/tools/schema.js +7 -7
- package/dist/tools/streaming.js +34 -4
- package/dist/tools/translation/assembler.js +247 -24
- package/dist/tools/translation/basicDetokenizer.js +80 -0
- package/dist/tools/translation/basicTokenizer.js +8 -3
- package/dist/tools/translation/disassembler.js +14 -0
- package/dist/tools/types.js +258 -7
- package/dist/tools/vice.js +5 -16
- package/dist/vicBitmap.js +1 -1
- package/dist/vice/mockServer.js +3 -0
- package/dist/vice/process.js +19 -0
- package/dist/vice/resourcePolicy.js +36 -0
- package/dist/vice/viceClient.js +22 -5
- package/doc/vice/support-matrix.md +17 -11
- package/mcp/schemas/c64_batch.schema.json +2 -1
- package/mcp/schemas/c64_config.schema.json +25 -475
- package/mcp/schemas/c64_debug.schema.json +3 -602
- package/mcp/schemas/c64_disk.schema.json +17 -344
- package/mcp/schemas/c64_drive.schema.json +2 -157
- package/mcp/schemas/c64_extract.schema.json +10 -364
- package/mcp/schemas/c64_graphics.schema.json +19 -426
- package/mcp/schemas/c64_input.schema.json +5 -318
- package/mcp/schemas/c64_memory.schema.json +23 -491
- package/mcp/schemas/c64_printer.schema.json +3 -258
- package/mcp/schemas/c64_program.schema.json +16 -491
- package/mcp/schemas/c64_rag.schema.json +1 -81
- package/mcp/schemas/c64_select_backend.schema.json +2 -36
- package/mcp/schemas/c64_sound.schema.json +20 -822
- package/mcp/schemas/c64_stream.schema.json +4 -75
- package/mcp/schemas/c64_system.schema.json +28 -348
- package/mcp/schemas/c64_vice.schema.json +2 -71
- package/mcp/server.json +1 -1
- package/mcp/tools.json +1983 -6910
- package/mcp.json +21 -1
- package/package.json +16 -8
- package/scripts/mockC64Server.mjs +44 -0
- package/scripts/run-coverage.mjs +27 -1
- package/scripts/smoke-run.sh +3 -2
- package/scripts/start.mjs +23 -1
- package/server.json +35 -2
- package/dist/.tsbuildinfo +0 -1
- package/doc/agent-state-spec.md +0 -243
- package/doc/audit/secret-handling.md +0 -61
- package/doc/plans/bun/baseline-metrics.md +0 -16
- package/doc/plans/bun/migration-checklist.md +0 -55
- package/doc/plans/bun/migration.md +0 -83
- package/doc/plans/improve-knowledge/notes.md +0 -43
- package/doc/plans/improve-knowledge/plan.md +0 -121
- package/doc/plans/improve-knowledge/rollout.md +0 -110
- package/doc/plans/meta-tools/README.md +0 -110
- package/doc/plans/meta-tools/analysis.md +0 -272
- package/doc/plans/meta-tools/catalog.md +0 -435
- package/doc/plans/meta-tools/notes.md +0 -192
- package/doc/plans/meta-tools/rollout.md +0 -368
- package/doc/plans/prompts/README.md +0 -96
- package/doc/plans/prompts/user-prompt-ideas.md +0 -147
- package/doc/plans/tool-consolidation/notes.md +0 -28
- package/doc/plans/tool-consolidation/plan.md +0 -169
- package/doc/plans/tool-consolidation/rollout.md +0 -104
- package/doc/plans/vice-backend-switch/plan.md +0 -486
- package/doc/plans/vice-backend-switch-2/plan.md +0 -48
- package/doc/plans/vice-support/notes.md +0 -63
- package/doc/plans/vice-support/plan.md +0 -88
- package/doc/plans/vice-support/rollout.md +0 -99
- package/doc/reviews/audit.md +0 -143
- package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
- package/doc/reviews/hardening/01-fable/prompt.md +0 -98
- package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
- package/doc/reviews/hardening/01-fable/review.md +0 -1234
- package/doc/reviews/vice-support-review.md +0 -185
- 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
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
150
|
-
await
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
241
|
+
finally {
|
|
242
|
+
await handle.close();
|
|
161
243
|
}
|
|
162
244
|
}
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
|
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:
|
|
243
|
-
maxIterations: optionalSchema(numberSchema({ description:
|
|
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
|
-
?
|
|
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 =
|
|
84
|
+
const sourcePath = resolveHostChildPath(outputPath, `source.${sourceType}`);
|
|
84
85
|
await fs.writeFile(sourcePath, source, "utf8");
|
|
85
|
-
const screenPath =
|
|
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 =
|
|
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 =
|
|
108
|
+
const manifestPath = resolveHostChildPath(outputPath, "manifest.json");
|
|
108
109
|
const manifest = {
|
|
109
110
|
runId,
|
|
110
111
|
sourceType,
|
|
@@ -3,7 +3,8 @@ 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
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { resolveHostPath } from "../../hostPaths.js";
|
|
7
8
|
import { captureConfigSnapshot, stripRedactedSecrets, validateSnapshotCategories } from "./configInventory.js";
|
|
8
9
|
import { normalizeErrorDetails } from "./util.js";
|
|
9
10
|
const configSnapshotAndRestoreArgsSchema = objectSchema({
|
|
@@ -17,6 +18,16 @@ const configSnapshotAndRestoreArgsSchema = objectSchema({
|
|
|
17
18
|
required: ["action", "path"],
|
|
18
19
|
additionalProperties: false,
|
|
19
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
|
+
}
|
|
20
31
|
export const tools = [
|
|
21
32
|
{
|
|
22
33
|
name: "config_snapshot_and_restore",
|
|
@@ -32,7 +43,7 @@ export const tools = [
|
|
|
32
43
|
if (action === "restore" && parsed.applyToFlash && platform === "vice") {
|
|
33
44
|
throw new ToolUnsupportedPlatformError("config_save_to_flash", platform, ["c64u", "u2"]);
|
|
34
45
|
}
|
|
35
|
-
const path =
|
|
46
|
+
const path = resolveHostPath(String(parsed.path), { purpose: action === "snapshot" ? "write" : "read" });
|
|
36
47
|
if (action === "snapshot") {
|
|
37
48
|
await fs.mkdir(dirname(path), { recursive: true });
|
|
38
49
|
const [version, info, snapshotData] = await Promise.all([
|
|
@@ -55,8 +66,7 @@ export const tools = [
|
|
|
55
66
|
}, { success: true });
|
|
56
67
|
}
|
|
57
68
|
if (action === "restore") {
|
|
58
|
-
const
|
|
59
|
-
const snapshot = JSON.parse(text);
|
|
69
|
+
const snapshot = await readSnapshotFile(path);
|
|
60
70
|
if (!snapshot || typeof snapshot !== "object") {
|
|
61
71
|
throw new ToolValidationError("Invalid snapshot file", { path: "$.path" });
|
|
62
72
|
}
|
|
@@ -79,8 +89,7 @@ export const tools = [
|
|
|
79
89
|
}
|
|
80
90
|
return jsonResult({ restored: true, categories: Object.keys(payload).length }, { success: true });
|
|
81
91
|
}
|
|
82
|
-
const
|
|
83
|
-
const snapshot = JSON.parse(text);
|
|
92
|
+
const snapshot = await readSnapshotFile(path);
|
|
84
93
|
const snapCats = validateSnapshotCategories(snapshot.categories ?? {});
|
|
85
94
|
const current = (await captureConfigSnapshot(ctx.client, { includeSecretValues: parsed.includeSecretValues === true })).categories;
|
|
86
95
|
const diff = {};
|
|
@@ -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:
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
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) =>
|
|
346
|
-
: [
|
|
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 >= (
|
|
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 >= (
|
|
371
|
+
if (results.length >= (limit + 1))
|
|
373
372
|
break;
|
|
374
373
|
}
|
|
375
374
|
}
|
|
376
375
|
}
|
|
377
|
-
if (results.length >= (
|
|
376
|
+
if (results.length >= (limit + 1))
|
|
378
377
|
break;
|
|
379
378
|
}
|
|
380
|
-
|
|
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 =
|
|
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);
|