@reed-io/dsh-workspace-move 0.0.0-stage → 0.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.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Transport between the Client surface and this Host plugin.
3
+ *
4
+ * The Host owns every filesystem and registry write, so the Client dialog talks
5
+ * to it over a same-origin JSON route. The route only answers requests a page
6
+ * served by this same Harness instance could make: the request must arrive from
7
+ * the loopback interface, carry the plugin header, come from a same-origin
8
+ * fetch or a non-browser client, and name this Host in an Origin header when it
9
+ * sends one. Anything else is refused, so the route stays inert for other
10
+ * origins a browser might visit.
11
+ * @module @reed-io/dsh-workspace-move
12
+ */
13
+
14
+ import { API_HEADER, API_PREFIX } from "./host-api-client.js";
15
+
16
+ export { API_HEADER, API_PREFIX };
17
+
18
+ /** Largest request body accepted, in bytes. */
19
+ const MAX_BODY_BYTES = 64 * 1024;
20
+
21
+ /**
22
+ * Whether an address literal belongs to the loopback interface.
23
+ * @param address - socket remote address.
24
+ * @returns whether it is loopback.
25
+ */
26
+ function isLoopbackAddress(address) {
27
+ if (typeof address !== "string" || address === "") return false;
28
+ if (address === "::1" || address === "127.0.0.1") return true;
29
+ if (address.startsWith("127.")) return true;
30
+ return address.startsWith("::ffff:127.");
31
+ }
32
+
33
+ /**
34
+ * Resolve the http origin part of a header value.
35
+ * @param value - header value such as `http://127.0.0.1:4379`.
36
+ * @returns the origin, or undefined when the value is not an origin.
37
+ */
38
+ function originOf(value) {
39
+ if (typeof value !== "string" || value === "" || value === "null") return undefined;
40
+ try {
41
+ return new URL(value).origin;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Decide whether one request may reach the Host API.
49
+ * @param request - incoming request.
50
+ * @returns whether the request is trusted.
51
+ */
52
+ export function isTrustedRequest(request) {
53
+ if (request.headers[API_HEADER] !== "1") return false;
54
+ if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
55
+ const site = request.headers["sec-fetch-site"];
56
+ if (site !== undefined && site !== "same-origin" && site !== "none") return false;
57
+ const host = request.headers.host;
58
+ if (typeof host !== "string" || host === "") return false;
59
+ const origin = request.headers.origin;
60
+ if (origin === undefined) return true;
61
+ const parsed = originOf(origin);
62
+ if (parsed === undefined) return false;
63
+ try {
64
+ return new URL(parsed).host === host;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Read a request body, refusing oversized payloads.
72
+ * @param request - incoming request.
73
+ * @returns the decoded UTF-8 body.
74
+ */
75
+ async function readBody(request) {
76
+ const chunks = [];
77
+ let size = 0;
78
+ for await (const chunk of request) {
79
+ size += chunk.length;
80
+ if (size > MAX_BODY_BYTES) throw new Error("request body is too large");
81
+ chunks.push(chunk);
82
+ }
83
+ return Buffer.concat(chunks).toString("utf8");
84
+ }
85
+
86
+ /**
87
+ * Write one JSON reply.
88
+ * @param response - server response.
89
+ * @param status - HTTP status.
90
+ * @param payload - JSON-serializable payload.
91
+ * @returns nothing.
92
+ */
93
+ function send(response, status, payload) {
94
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
95
+ response.writeHead(status, {
96
+ "content-type": "application/json; charset=utf-8",
97
+ "content-length": String(body.length),
98
+ "cache-control": "no-store",
99
+ "x-content-type-options": "nosniff"
100
+ });
101
+ response.end(body);
102
+ }
103
+
104
+ /**
105
+ * Build the request handler over one action table.
106
+ * @param actions - map of action name to async function of the parsed body.
107
+ * @param logger - Host logger used for contained failures.
108
+ * @returns a node request handler.
109
+ */
110
+ export function createHandler(actions, logger) {
111
+ return (request, response) => {
112
+ const run = async () => {
113
+ if (request.method !== "POST") {
114
+ send(response, 405, { ok: false, code: "method-not-allowed" });
115
+ return;
116
+ }
117
+ if (!isTrustedRequest(request)) {
118
+ send(response, 403, { ok: false, code: "untrusted-request" });
119
+ return;
120
+ }
121
+ const url = new URL(request.url ?? "/", "http://localhost");
122
+ const name = url.pathname.slice(API_PREFIX.length).replace(/^\//u, "");
123
+ const action = actions[name];
124
+ if (action === undefined) {
125
+ send(response, 404, { ok: false, code: "unknown-action" });
126
+ return;
127
+ }
128
+ let body;
129
+ try {
130
+ const text = await readBody(request);
131
+ body = text === "" ? {} : JSON.parse(text);
132
+ } catch (error) {
133
+ send(response, 400, { ok: false, code: "bad-request", message: String(error) });
134
+ return;
135
+ }
136
+ try {
137
+ const result = await action(body);
138
+ send(response, 200, { ok: true, result });
139
+ } catch (error) {
140
+ const failure = error instanceof Error ? error : new Error(String(error));
141
+ logger?.warn?.(`workspace-move: action '${name}' failed: ${failure.message}`);
142
+ send(response, 200, {
143
+ ok: false,
144
+ code: failure.code ?? "failed",
145
+ message: failure.message,
146
+ details: failure.details ?? {}
147
+ });
148
+ }
149
+ };
150
+ run().catch((error) => {
151
+ logger?.warn?.(`workspace-move: request handling failed: ${String(error)}`);
152
+ try {
153
+ send(response, 500, { ok: false, code: "internal" });
154
+ } catch {
155
+ response.destroy?.();
156
+ }
157
+ });
158
+ };
159
+ }
package/lib/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Host half of the workspace move plugin: the route the Client dialog calls, the
3
+ * agent tool, and the in-memory record that makes a move undoable until the
4
+ * Harness restarts.
5
+ * @module @reed-io/dsh-workspace-move
6
+ */
7
+ export { apply, inject, name, undoState } from "./plugin.js";
package/lib/paths.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Session artifact layout helpers. The jsonl backend derives the on-disk folder
3
+ * from a session's `cwd` and its header; these mirrors let the move engine place
4
+ * and verify artifacts without importing backend internals.
5
+ * @module @reed-io/dsh-workspace-move
6
+ */
7
+ import { join } from "node:path";
8
+
9
+ const PROJECT_KEY_LIMIT = 251;
10
+
11
+ /**
12
+ * Encode one path segment the way the backend encodes session folders.
13
+ * @param value - segment to encode.
14
+ * @returns the portable segment.
15
+ */
16
+ export function encodeSegment(value) {
17
+ if (value.length === 0) throw new Error("cannot encode an empty path segment");
18
+ if (value === ".") return "~002E";
19
+ if (value === "..") return "~002E~002E";
20
+ let out = "";
21
+ for (let index = 0; index < value.length; index += 1) {
22
+ const code = value.charCodeAt(index);
23
+ const char = String.fromCharCode(code);
24
+ if (char !== "~" && /^[A-Za-z0-9._-]$/u.test(char)) out += char;
25
+ else out += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /**
31
+ * Fold a working directory into a project folder name.
32
+ * @param cwd - session working directory.
33
+ * @returns the project key used as a storage folder name.
34
+ */
35
+ export function projectKey(cwd) {
36
+ if (cwd.length === 0) throw new Error("cannot encode an empty project path");
37
+ let readable = "";
38
+ let separatorRun = false;
39
+ for (let index = 0; index < cwd.length; index += 1) {
40
+ const code = cwd.charCodeAt(index);
41
+ const char = String.fromCharCode(code);
42
+ if (char === "/" || char === "\\" || char === ":") {
43
+ if (!separatorRun) readable += "-";
44
+ separatorRun = true;
45
+ } else if (char !== "~" && /^[A-Za-z0-9._-]$/u.test(char)) {
46
+ readable += char;
47
+ separatorRun = false;
48
+ } else {
49
+ readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
50
+ separatorRun = false;
51
+ }
52
+ }
53
+ return `--${(readable.replace(/^-+/u, "") || "root").slice(0, PROJECT_KEY_LIMIT)}--`;
54
+ }
55
+
56
+ /**
57
+ * Locate the folder holding every session of one working directory.
58
+ * @param root - persistence root.
59
+ * @param cwd - session working directory, or undefined for the cwd-less group.
60
+ * @returns absolute project folder.
61
+ */
62
+ export function projectDir(root, cwd) {
63
+ return cwd === undefined ? join(root, "_no-cwd") : join(root, projectKey(cwd));
64
+ }
65
+
66
+ /**
67
+ * Locate one session's artifact folder.
68
+ * @param root - persistence root.
69
+ * @param cwd - session working directory.
70
+ * @param id - session id.
71
+ * @returns absolute session folder.
72
+ */
73
+ export function sessionDir(root, cwd, id) {
74
+ return join(projectDir(root, cwd), encodeSegment(id));
75
+ }
76
+
77
+ /**
78
+ * Tag one artifact filename with its format version and compression.
79
+ * @param version - session format version.
80
+ * @param compression - artifact compression ("zstd" by default, "none" when raw).
81
+ * @returns artifact filename.
82
+ */
83
+ export function logFilename(version, compression = "zstd") {
84
+ const suffix = compression === undefined || compression === "none" ? "" : `.${compression}`;
85
+ return version === 0 ? `session.jsonl${suffix}` : `session.v${version}.jsonl${suffix}`;
86
+ }
87
+
88
+ /**
89
+ * Locate one session's current-format artifact.
90
+ * @param root - persistence root.
91
+ * @param cwd - session working directory.
92
+ * @param id - session id.
93
+ * @param version - session format version.
94
+ * @param compression - artifact compression.
95
+ * @returns absolute artifact path.
96
+ */
97
+ export function logPath(root, cwd, id, version = 4, compression = "zstd") {
98
+ return join(sessionDir(root, cwd, id), logFilename(version, compression));
99
+ }
package/lib/plan.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Planning a move before anything is written: which workspace is the subject,
3
+ * where it lands, which nested registrations must follow it, and why a request
4
+ * has to be refused.
5
+ *
6
+ * A plan is pure data. Everything it claims was read from the filesystem and the
7
+ * registry, so the UI can show exactly what will happen and the engine can later
8
+ * refuse a plan whose preconditions no longer hold.
9
+ * @module @reed-io/dsh-workspace-move
10
+ */
11
+ import { basename, join, resolve } from "node:path";
12
+ import { canonicalPath, exists, isInside, samePath } from "./fsx.js";
13
+ import { recordOf } from "./registry-ops.js";
14
+ import { collectSessions, isWithin } from "./sessions.js";
15
+
16
+ /**
17
+ * Build one failure object the surfaces can render.
18
+ * @param code - stable machine-readable reason.
19
+ * @param message - human-readable explanation.
20
+ * @param details - extra structured context.
21
+ * @returns the failure.
22
+ */
23
+ export function fail(code, message, details = {}) {
24
+ return { code, message, details };
25
+ }
26
+
27
+ /**
28
+ * Problems that make a folder name unusable as a workspace folder.
29
+ * @param name - proposed folder name.
30
+ * @returns a failure, or undefined when the name is fine.
31
+ */
32
+ export function nameProblem(name) {
33
+ if (typeof name !== "string" || name.trim() === "") return fail("name-empty", "Enter a folder name.");
34
+ if (name !== name.trim()) return fail("name-padded", "A folder name cannot start or end with a space.");
35
+ if (name === "." || name === "..") return fail("name-relative", "A folder name cannot be '.' or '..'.");
36
+ if (/[\\/:*?"<>|\u0000-\u001F]/u.test(name)) return fail("name-invalid", "A folder name cannot contain \\ / : * ? \" < > |.");
37
+ if (/[. ]$/u.test(name)) return fail("name-trailing", "A folder name cannot end with a dot or a space.");
38
+ return undefined;
39
+ }
40
+
41
+ /**
42
+ * Describe where a workspace lives and where a move would send it.
43
+ * @param sourcePath - the workspace's current directory.
44
+ * @param targetParent - the destination parent folder.
45
+ * @param folderName - the folder name at the destination (defaults to the current name).
46
+ * @returns the resolved destination paths.
47
+ */
48
+ export async function resolveTarget(sourcePath, targetParent, folderName) {
49
+ const parent = resolve(targetParent);
50
+ if (!isAbsolute(targetParent)) return { failure: fail("target-relative", "Choose an absolute destination folder.") };
51
+ if (!(await exists(parent))) return { failure: fail("target-missing", `The folder '${parent}' does not exist.`, { parent }) };
52
+ const name = typeof folderName === "string" && folderName.trim() !== "" ? folderName.trim() : basename(sourcePath);
53
+ const problem = nameProblem(name);
54
+ if (problem !== undefined) return { failure: problem };
55
+ const destination = join(parent, name);
56
+ if (samePath(destination, sourcePath)) {
57
+ return { failure: fail("target-same", "The workspace is already in that folder.") };
58
+ }
59
+ if (isInside(destination, sourcePath)) {
60
+ return { failure: fail("target-inside", "A workspace cannot be moved into its own folder.") };
61
+ }
62
+ if (isInside(sourcePath, destination)) {
63
+ return { failure: fail("target-ancestor", "That destination is inside the workspace's own current folder chain.") };
64
+ }
65
+ if (await exists(destination)) {
66
+ return { failure: fail("target-exists", `'${destination}' already exists. Pick another name.`, { destination }) };
67
+ }
68
+ return { canonicalParent: await canonicalPath(parent), destination, folderName: name, renamed: !samePath(destination, sourcePath) && basename(sourcePath) !== name };
69
+ }
70
+
71
+ /**
72
+ * Whether a path is absolute on the running platform.
73
+ * @param value - candidate path.
74
+ * @returns whether it is absolute.
75
+ */
76
+ function isAbsolute(value) {
77
+ if (typeof value !== "string" || value === "") return false;
78
+ if (process.platform === "win32") return /^[A-Za-z]:[\\/]/u.test(value) || /^\\\\/u.test(value);
79
+ return value.startsWith("/");
80
+ }
81
+
82
+ /**
83
+ * Nested registrations whose path lives inside the moving folder.
84
+ * @param registry - workspace registry service.
85
+ * @param workspaceId - the workspace being moved.
86
+ * @param sourcePath - the folder that will move.
87
+ * @param destination - where that folder will be.
88
+ * @returns one entry per affected registration, with its planned path and title.
89
+ */
90
+ export function nestedMoves(registry, workspaceId, sourcePath, destination) {
91
+ return registry
92
+ .list()
93
+ .filter((entity) => entity.id !== workspaceId && isWithin(entity.path, sourcePath))
94
+ .map((entity) => {
95
+ const next = join(destination, entity.path.slice(sourcePath.length).replace(/^[\\/]+/u, ""));
96
+ return { id: entity.id, from: entity.path, to: next, title: entity.title };
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Sessions of one workspace that currently have running work.
102
+ * @param ctx - Host context.
103
+ * @param sessionIds - sessions to check.
104
+ * @returns the active sessions with the kind of work each reports.
105
+ */
106
+ export async function activeSessions(ctx, sessionIds) {
107
+ const active = [];
108
+ for (const sessionId of sessionIds) {
109
+ let activities = [];
110
+ try {
111
+ activities = await ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
112
+ } catch {
113
+ activities = [];
114
+ }
115
+ const kinds = (Array.isArray(activities) ? activities : []).flat().map((item) => item?.kind ?? "busy").filter(Boolean);
116
+ if (kinds.length > 0) active.push({ sessionId, kinds: [...new Set(kinds)] });
117
+ }
118
+ return active;
119
+ }
120
+
121
+ /**
122
+ * Build the full plan for one requested move.
123
+ * @param ctx - Host context.
124
+ * @param request - workspace id, destination parent and optional folder name.
125
+ * @returns `{ plan }` or `{ failure }`.
126
+ */
127
+ export async function buildPlan(ctx, request) {
128
+ const registry = ctx.workspaceRegistry;
129
+ if (registry === undefined) return { failure: fail("registry-unavailable", "The workspace registry is unavailable.") };
130
+ const workspaceId = typeof request?.workspaceId === "string" ? request.workspaceId : "";
131
+ const entity = registry.list().find((candidate) => candidate.id === workspaceId);
132
+ const record = recordOf(registry, workspaceId);
133
+ if (entity === undefined || record === undefined) return { failure: fail("workspace-unknown", "That workspace is no longer registered.") };
134
+ const resolved = await resolveTarget(record.path, request?.targetParent ?? "", request?.folderName);
135
+ if (resolved.failure !== undefined) return { failure: resolved.failure };
136
+ const active = await activeSessions(ctx, record.sessionIds);
137
+ const sessions = await collectSessions(ctx, record);
138
+ const nested = nestedMoves(registry, workspaceId, record.path, resolved.destination);
139
+ const currentName = basename(record.path);
140
+ return {
141
+ plan: {
142
+ workspaceId,
143
+ title: record.title,
144
+ source: record.path,
145
+ sourceCanonical: await canonicalPath(record.path),
146
+ destination: resolved.destination,
147
+ targetParent: resolved.canonicalParent,
148
+ folderName: resolved.folderName,
149
+ renamed: resolved.folderName !== currentName,
150
+ sessionIds: [...record.sessionIds],
151
+ sessions: sessions.map((session) => ({ id: session.id, listed: session.listed, path: session.path })),
152
+ nested,
153
+ active
154
+ }
155
+ };
156
+ }
package/lib/plugin.js ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Host half of the workspace move plugin: wires the move engine to the Client
3
+ * dialog route, the agent tool, and the in-memory undo record.
4
+ * @module @reed-io/dsh-workspace-move
5
+ */
6
+ import { defineTool } from "@deepseek-ai/dsh-tools";
7
+ import { exists, samePath } from "./fsx.js";
8
+ import { performMove, toError, undoMove } from "./engine.js";
9
+ import { API_PREFIX, createHandler } from "./host-api.js";
10
+ import { browse, createFolder } from "./directory-browse.js";
11
+ import { buildPlan } from "./plan.js";
12
+
13
+ /** Cordis plugin name. */
14
+ export const name = "workspace-move";
15
+
16
+ /** Services the plugin needs before it can answer a move request. */
17
+ export const inject = ["webServer", "workspaceRegistry", "sessionPersistence", "tools"];
18
+
19
+ /**
20
+ * The single move a running Harness can undo. Kept in memory on purpose: the
21
+ * record describes live folders and a registry that only exist for this run.
22
+ */
23
+ export const undoState = { last: undefined };
24
+
25
+ /**
26
+ * Product view of one finished move: what a caller may report, and whether undo
27
+ * is offered for it.
28
+ * @param record - the undo record kept by the engine.
29
+ * @returns the summarized outcome.
30
+ */
31
+ function summarize(record) {
32
+ undoState.last = record.allowed ? record : undefined;
33
+ return {
34
+ destination: record.destination,
35
+ source: record.source,
36
+ title: record.title,
37
+ sessions: record.sessions.length,
38
+ nested: record.nested.length,
39
+ undoable: undoState.last !== undefined
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Mount the plugin.
45
+ * @param ctx - Cordis context.
46
+ */
47
+ export function apply(ctx) {
48
+ const logger = ctx.logger;
49
+ const actions = {
50
+ /**
51
+ * Review a requested move without touching anything.
52
+ * @param body - workspace id and destination.
53
+ * @returns the plan or the reason it cannot proceed.
54
+ */
55
+ async plan(body) {
56
+ const built = await buildPlan(ctx, body);
57
+ if (built.failure !== undefined) throw toError(built.failure);
58
+ return built.plan;
59
+ },
60
+ /**
61
+ * Execute a requested move and remember it for undo.
62
+ * @param body - workspace id and destination.
63
+ * @returns the resulting locations.
64
+ */
65
+ async move(body) {
66
+ return summarize(await performMove(ctx, body));
67
+ },
68
+ /**
69
+ * Report what could be undone right now.
70
+ * @returns the recorded move, or nothing.
71
+ */
72
+ async undoInfo() {
73
+ const last = undoState.last;
74
+ if (last === undefined) return { available: false };
75
+ const registered = ctx.workspaceRegistry?.list().find((entity) => entity.id === last.workspaceId);
76
+ const occupied = await exists(last.source).catch(() => false);
77
+ const available = registered !== undefined && samePath(registered.path, last.destination) && !occupied;
78
+ return { available, title: last.title, source: last.source, destination: last.destination, workspaceId: last.workspaceId, occupied };
79
+ },
80
+ /**
81
+ * Undo the recorded move.
82
+ * @returns nothing.
83
+ */
84
+ async undo() {
85
+ const last = undoState.last;
86
+ if (last === undefined) throw toError({ code: "nothing-to-undo", message: "There is no move to undo.", details: {} });
87
+ await undoMove(ctx, last);
88
+ undoState.last = undefined;
89
+ return { restored: last.source };
90
+ },
91
+ /**
92
+ * List the subfolders of one destination folder.
93
+ * @param body - the folder to list.
94
+ * @returns folders, breadcrumbs and the entry cap.
95
+ */
96
+ async browse(body) {
97
+ return browse(body?.path);
98
+ },
99
+ /**
100
+ * Create one folder inside a destination folder.
101
+ * @param body - parent folder and new folder name.
102
+ * @returns the new folder path.
103
+ */
104
+ async createFolder(body) {
105
+ return { path: await createFolder(body?.parent, body?.name) };
106
+ }
107
+ };
108
+ try {
109
+ ctx.webServer.register({ kind: "prefix", path: API_PREFIX, handler: createHandler(actions, logger) });
110
+ } catch (error) {
111
+ // A hot reload can re-apply this plugin while the previous route is still
112
+ // mounted. The handler only reads module-level state, so the existing route
113
+ // serves the new instance just as well; failing here would take the plugin
114
+ // down for no reason.
115
+ if (!/duplicate prefix route/i.test(String(error?.message ?? error))) throw error;
116
+ logger?.warn?.(`workspace-move: ${API_PREFIX} is already mounted; reusing the existing route`);
117
+ }
118
+
119
+ const tool = defineTool({
120
+ name: "move_workspace",
121
+ description:
122
+ "Move one registered workspace to another folder, together with its folder tree, nested workspaces registered inside it, and its stored sessions. A different folder name renames the workspace. Fails while its sessions are running; the Harness GUI offers undo for the last move of this run.",
123
+ parameters: {
124
+ workspaceId: { type: "string", description: "Id of the workspace to move, as reported by the workspace list." },
125
+ targetParent: { type: "string", description: "Absolute path of the folder that will contain the moved workspace." },
126
+ folderName: { type: "string", description: "Folder name inside the destination parent. Omit to keep the current folder name." }
127
+ },
128
+ output: {
129
+ schema: {
130
+ type: "object",
131
+ additionalProperties: false,
132
+ properties: {
133
+ destination: { type: "string", required: true },
134
+ source: { type: "string", required: true },
135
+ title: { type: "string", required: true },
136
+ sessions: { type: "number", required: true },
137
+ nested: { type: "number", required: true },
138
+ undoable: { type: "boolean", required: true }
139
+ }
140
+ },
141
+ render: (_args, value) => [{ type: "text", text: JSON.stringify(value) }]
142
+ },
143
+ async execute(args) {
144
+ return summarize(
145
+ await performMove(ctx, { workspaceId: args.workspaceId, targetParent: args.targetParent, folderName: args.folderName })
146
+ );
147
+ },
148
+ presentCall: (args) => ({ card: "generic", title: "move_workspace", rawInput: args })
149
+ });
150
+
151
+ try {
152
+ ctx.tools.register(tool);
153
+ } catch (error) {
154
+ // Same reload situation as the route above: an earlier copy of this plugin
155
+ // may still own the tool name.
156
+ if (!/already (?:registered|exists)|duplicate/i.test(String(error?.message ?? error))) throw error;
157
+ logger?.warn?.("workspace-move: move_workspace is already registered; keeping the existing tool");
158
+ }
159
+ }