[feat] add acknowledged remote clipboard read and write

This commit is contained in:
dijunkun
2026-11-06 20:20:25 +08:00
parent 0eab020c38
commit 0aed57ed4d
13 changed files with 217 additions and 5 deletions
+4
View File
@@ -250,3 +250,7 @@ npm run pack:npm -- --out-dir /absolute/path/artifacts
更新后的被控端通过 `capabilities.input_ack` 声明输入处理回执。动作结果的 `remote_execution` 表示远端原生输入处理状态,应用界面的实际结果由后续观察确认。旧被控端返回 `unsupported`。回执等待有明确期限,超时动作保留结果不确定状态。
`crossdesk_wait` 使用已有截图的 `observation_id`,等待对应区域变化(`changed`)或持续稳定(`stable`)。可设置 `stable_ms` 和最长 25000 毫秒的 `timeout_ms`。满足条件后返回新截图,切换显示器、断开和取消会结束等待。
### 远端剪贴板
`crossdesk_clipboard_get` 读取远端文本剪贴板,`crossdesk_clipboard_set` 设置文本,空字符串清空。读取上限为 128 KiB,单次工具写入上限为 16 KiB UTF-8。写入需要 `operation_id`,相同请求查询原结果。工具使用已授权控制会话,并遵守人工暂停及解锁等待状态。更新后的被控端通过 `capabilities.clipboard` 声明支持,写入成功由系统剪贴板更新回执确认。
+4 -2
View File
@@ -173,6 +173,8 @@ export class NativeClient {
if (this.pending.size >= 32 || this.child.stdin!.writableLength > 262144)
return Promise.reject(new Failure("BUSY", false, true));
const id = ++this.sequence;
const sideEffect = method === "action" || method === "unlock" ||
(method === "clipboard" && args.operation === "set");
const line = JSON.stringify({ ...args, id, method }) + "\n";
if (Buffer.byteLength(line) > 65536)
return Promise.reject(new Failure("MESSAGE_TOO_LARGE"));
@@ -190,7 +192,7 @@ export class NativeClient {
request_id: id,
}) + "\n",
);
reject(new Failure(code, method === "action" || method === "unlock"));
reject(new Failure(code, sideEffect));
};
const abort = () => cancel("CANCELLED");
const timer = setTimeout(() => cancel("NATIVE_TIMEOUT"), timeout);
@@ -198,7 +200,7 @@ export class NativeClient {
this.pending.set(id, {
resolve,
reject,
action: method === "action" || method === "unlock",
action: sideEffect,
clean: () => {
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
+8
View File
@@ -312,6 +312,14 @@ export function createServer(
"Enable or disable control within the user-authorized scope. Cannot override human pause or upgrade an observe-only connection.",
async (p) => output(await sessions.control(p.session_id, p.enabled)),
);
add("crossdesk_clipboard_get", z.strictObject({ session_id: id }),
"Read the remote text clipboard within the authorized control session.",
async (p, ctx) => output(await sessions.clipboard({ ...p, operation: "get" }, ctx.mcpReq.signal)),
{ readOnly: true });
add("crossdesk_clipboard_set", z.strictObject({ session_id: id, operation_id: id,
text: z.string().max(16384).refine((text) => Buffer.byteLength(text) <= 16384 && !text.includes("\0")) }),
"Set or clear the remote text clipboard. Reuse the same operation_id to query the original result. Returns applied after the remote clipboard update.",
async (p, ctx) => output(await sessions.clipboard({ ...p, operation: "set" }, ctx.mcpReq.signal)));
add(
"crossdesk_select_display",
z.strictObject({ session_id: id, display_index: z.int().min(0).max(63) }),
+7
View File
@@ -466,6 +466,13 @@ export class Sessions {
if (result.matched) result.observation = await this.screenshot({ session_id }, signal);
return result;
}
async clipboard(p: Data, signal?: AbortSignal): Promise<Data> {
const { session_id, ...args } = p;
const s = this.session(session_id);
this.checkUnlock(s);
if (s.unlock && this.unlockPending(s.unlock)) throw new Failure("UNLOCK_PENDING");
return s.native.request("clipboard", args, 7000, signal);
}
async control(id: string, enabled: boolean) {
if (!enabled) this.cancelUnlock(this.session(id));
return this.session(id).native.request("control", { enabled });