@agent-sh/computer-use-linux 0.4.9 → 0.5.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/README.md +34 -9
- package/npm/README.md +6 -0
- package/package.json +26 -3
- package/pi/extension/THIRD_PARTY_NOTICES.txt +671 -0
- package/pi/extension/generated-tools.ts +1334 -0
- package/pi/extension/index.ts +589 -196
- package/pi/extension/mcp-client.bundle.cjs +62 -0
- package/skills/computer-use-linux/SKILL.md +20 -10
- package/skills/computer-use-linux/references/pi-setup.md +95 -24
package/pi/extension/index.ts
CHANGED
|
@@ -1,262 +1,655 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pi
|
|
2
|
+
* Native Pi integration for computer-use-linux.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Prerequisite: pi-mcp-adapter (npm:pi-mcp-adapter) must be installed.
|
|
4
|
+
* Pi starts with one small loader tool. The real Computer Use tools remain
|
|
5
|
+
* inactive until the loader enables them, then call one session-scoped MCP
|
|
6
|
+
* process with their exact upstream schemas.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
AgentToolResult,
|
|
11
|
+
ExtensionAPI,
|
|
12
|
+
ExtensionContext,
|
|
13
|
+
ToolResultEvent,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
11
16
|
import {
|
|
12
17
|
accessSync,
|
|
13
18
|
constants,
|
|
14
19
|
existsSync,
|
|
15
|
-
mkdirSync,
|
|
16
20
|
readFileSync,
|
|
17
|
-
writeFileSync,
|
|
18
21
|
} from "node:fs";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
19
23
|
import { homedir } from "node:os";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { Type, type TSchema } from "typebox";
|
|
26
|
+
import {
|
|
27
|
+
GENERATED_MCP_TOOLS,
|
|
28
|
+
GENERATED_OPTIONAL_MCP_TOOLS,
|
|
29
|
+
GENERATED_SERVER_VERSION,
|
|
30
|
+
GENERATED_SHELL_TOOL_CATALOG_HASH,
|
|
31
|
+
GENERATED_TOOL_CATALOG_HASH,
|
|
32
|
+
type GeneratedMcpToolDefinition,
|
|
33
|
+
} from "./generated-tools.ts";
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const PACKAGE_NAME = "@agent-sh/computer-use-linux";
|
|
37
|
+
const ACTIVE_TOOLS_ENTRY = "computer-use-linux-active-tools";
|
|
38
|
+
const LOADER_TOOL_NAME = "computer_use_linux_tools";
|
|
39
|
+
const TOOL_PREFIX = "computer_use_linux_";
|
|
40
|
+
const DEFAULT_TOOLS = [
|
|
41
|
+
"doctor",
|
|
42
|
+
"list_windows",
|
|
43
|
+
"focused_window",
|
|
44
|
+
"get_app_state",
|
|
45
|
+
] as const;
|
|
46
|
+
const MAX_RESULT_TEXT_CHARS = 200_000;
|
|
47
|
+
const MAX_RESULT_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
48
|
+
const MAX_RESULT_IMAGES = 4;
|
|
49
|
+
const TRUNCATION_NOTICE =
|
|
50
|
+
"[Result truncated by the Pi extension. Request a smaller/bounded result.]";
|
|
51
|
+
const SHELL_ENABLED = process.env.COMPUTER_USE_LINUX_ENABLE_SHELL === "1";
|
|
52
|
+
const AVAILABLE_MCP_TOOLS: readonly GeneratedMcpToolDefinition[] = SHELL_ENABLED
|
|
53
|
+
? [...GENERATED_MCP_TOOLS, ...GENERATED_OPTIONAL_MCP_TOOLS]
|
|
54
|
+
: GENERATED_MCP_TOOLS;
|
|
55
|
+
|
|
56
|
+
type PiContent = AgentToolResult<Record<string, unknown>>["content"][number];
|
|
57
|
+
|
|
58
|
+
interface McpCallToolResult {
|
|
59
|
+
content?: unknown[];
|
|
60
|
+
isError?: boolean;
|
|
61
|
+
structuredContent?: unknown;
|
|
62
|
+
}
|
|
22
63
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
64
|
+
interface NativeMcpClient {
|
|
65
|
+
callTool(
|
|
66
|
+
name: string,
|
|
67
|
+
args: Record<string, unknown>,
|
|
68
|
+
signal?: AbortSignal,
|
|
69
|
+
): Promise<McpCallToolResult>;
|
|
70
|
+
close(): Promise<void>;
|
|
71
|
+
}
|
|
26
72
|
|
|
27
|
-
|
|
28
|
-
|
|
73
|
+
interface NativeMcpClientConstructor {
|
|
74
|
+
new (options: {
|
|
75
|
+
binaryPath: string;
|
|
76
|
+
binaryArgs?: string[];
|
|
77
|
+
clientVersion: string;
|
|
78
|
+
env: Record<string, string>;
|
|
79
|
+
expectedCatalogHash: string;
|
|
80
|
+
expectedServerVersion: string;
|
|
81
|
+
requestTimeoutMs?: number;
|
|
82
|
+
}): NativeMcpClient;
|
|
83
|
+
}
|
|
29
84
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* when it starts up, alongside ~/.config/mcp/mcp.json and .mcp.json.
|
|
33
|
-
*/
|
|
34
|
-
function getPiAgentMcpConfigPath(): string {
|
|
35
|
-
const configured = env.PI_CODING_AGENT_DIR?.trim();
|
|
36
|
-
if (configured) {
|
|
37
|
-
return join(configured, "mcp.json");
|
|
38
|
-
}
|
|
39
|
-
return join(homedir(), ".pi", "agent", "mcp.json");
|
|
85
|
+
interface NativeMcpClientModule {
|
|
86
|
+
ComputerUseMcpClient: NativeMcpClientConstructor;
|
|
40
87
|
}
|
|
41
88
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
89
|
+
interface BinaryLaunch {
|
|
90
|
+
binaryPath: string;
|
|
91
|
+
env: Record<string, string>;
|
|
92
|
+
}
|
|
45
93
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
94
|
+
interface ExtensionDependencies {
|
|
95
|
+
findBinary?: () => BinaryLaunch | null;
|
|
96
|
+
loadClientModule?: () => NativeMcpClientModule;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ORIGINAL_TOOL_NAMES = AVAILABLE_MCP_TOOLS.map((tool) => tool.name);
|
|
100
|
+
const NATIVE_TOOL_NAMES = AVAILABLE_MCP_TOOLS.map(
|
|
101
|
+
(tool) => `${TOOL_PREFIX}${tool.name}`,
|
|
102
|
+
);
|
|
103
|
+
const NATIVE_TOOL_NAME_SET = new Set(NATIVE_TOOL_NAMES);
|
|
104
|
+
const TOOL_BY_ORIGINAL_NAME: Map<string, GeneratedMcpToolDefinition> = new Map(
|
|
105
|
+
AVAILABLE_MCP_TOOLS.map((tool) => [tool.name, tool]),
|
|
106
|
+
);
|
|
107
|
+
const TOOL_ALIASES: Record<string, string[]> = {
|
|
108
|
+
activate_window: ["focus", "raise", "switch window"],
|
|
109
|
+
click: ["press button", "mouse"],
|
|
110
|
+
doctor: ["diagnose", "readiness", "health", "setup"],
|
|
111
|
+
drag: ["mouse drag"],
|
|
112
|
+
focused_window: ["active window", "focus"],
|
|
113
|
+
get_app_state: ["accessibility", "observe", "snapshot", "screen state"],
|
|
114
|
+
list_apps: ["applications", "processes"],
|
|
115
|
+
list_windows: ["windows", "titles"],
|
|
116
|
+
move_window: ["position window"],
|
|
117
|
+
perform_action: ["activate element", "press element", "toggle"],
|
|
118
|
+
press_key: ["keyboard", "shortcut", "hotkey"],
|
|
119
|
+
resize_window: ["window size"],
|
|
120
|
+
run_shell: ["shell", "command", "terminal"],
|
|
121
|
+
screenshot: ["image", "screen capture"],
|
|
122
|
+
scroll: ["wheel", "page"],
|
|
123
|
+
set_value: ["input value", "text field", "slider"],
|
|
124
|
+
setup_accessibility: ["at-spi", "accessibility setup"],
|
|
125
|
+
setup_window_targeting: ["gnome extension", "window setup"],
|
|
126
|
+
type_text: ["write text", "keyboard text"],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const LoaderToolNameSchema = StringEnum(ORIGINAL_TOOL_NAMES);
|
|
130
|
+
const LoaderParameters = Type.Object(
|
|
131
|
+
{
|
|
132
|
+
tools: Type.Optional(
|
|
133
|
+
Type.Array(LoaderToolNameSchema, {
|
|
134
|
+
description:
|
|
135
|
+
"Exact Computer Use tool names to enable starting next turn for this session.",
|
|
136
|
+
minItems: 1,
|
|
137
|
+
uniqueItems: true,
|
|
138
|
+
}),
|
|
139
|
+
),
|
|
140
|
+
query: Type.Optional(
|
|
141
|
+
Type.String({
|
|
142
|
+
description:
|
|
143
|
+
"Capability to search for when exact tool names are not known.",
|
|
144
|
+
minLength: 1,
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
{ additionalProperties: false },
|
|
149
|
+
);
|
|
50
150
|
|
|
51
|
-
function
|
|
52
|
-
|
|
151
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
152
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
153
|
+
}
|
|
53
154
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
155
|
+
function executable(path: string): boolean {
|
|
156
|
+
try {
|
|
157
|
+
accessSync(path, constants.X_OK);
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
59
161
|
}
|
|
162
|
+
}
|
|
60
163
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
164
|
+
function runtimeEnvironment(): Record<string, string> {
|
|
165
|
+
const allowed = new Set([
|
|
166
|
+
"PATH",
|
|
167
|
+
"HOME",
|
|
168
|
+
"USER",
|
|
169
|
+
"LOGNAME",
|
|
170
|
+
"SHELL",
|
|
171
|
+
"LANG",
|
|
172
|
+
"LANGUAGE",
|
|
173
|
+
"TERM",
|
|
174
|
+
"TMPDIR",
|
|
175
|
+
"DISPLAY",
|
|
176
|
+
"WAYLAND_DISPLAY",
|
|
177
|
+
"XAUTHORITY",
|
|
178
|
+
"DBUS_SESSION_BUS_ADDRESS",
|
|
179
|
+
"XDG_RUNTIME_DIR",
|
|
180
|
+
"XDG_CURRENT_DESKTOP",
|
|
181
|
+
"XDG_SESSION_TYPE",
|
|
182
|
+
"XDG_SESSION_DESKTOP",
|
|
183
|
+
"XDG_DATA_DIRS",
|
|
184
|
+
"XDG_CONFIG_HOME",
|
|
185
|
+
"XDG_CACHE_HOME",
|
|
186
|
+
"XDG_STATE_HOME",
|
|
187
|
+
"DESKTOP_SESSION",
|
|
188
|
+
"GDMSESSION",
|
|
189
|
+
"HYPRLAND_INSTANCE_SIGNATURE",
|
|
190
|
+
"I3SOCK",
|
|
191
|
+
"SWAYSOCK",
|
|
192
|
+
"YDOTOOL_SOCKET",
|
|
193
|
+
"GSETTINGS_SCHEMA_DIR",
|
|
194
|
+
"GIO_EXTRA_MODULES",
|
|
195
|
+
"GI_TYPELIB_PATH",
|
|
196
|
+
"LD_LIBRARY_PATH",
|
|
197
|
+
"NIX_LD",
|
|
198
|
+
"NIX_LD_LIBRARY_PATH",
|
|
199
|
+
"RUST_LOG",
|
|
200
|
+
"COMPUTER_USE_LINUX_COSMIC_HELPER",
|
|
201
|
+
"COMPUTER_USE_LINUX_ENABLE_SHELL",
|
|
202
|
+
"COMPUTER_USE_LINUX_FORCE_PORTAL_KEYBOARD",
|
|
203
|
+
"COMPUTER_USE_LINUX_FORCE_PORTAL_POINTER",
|
|
204
|
+
"COMPUTER_USE_LINUX_FORCE_XDOTOOL_KEYBOARD",
|
|
205
|
+
"COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD",
|
|
206
|
+
"COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER",
|
|
207
|
+
"COMPUTER_USE_LINUX_PORTAL_SCROLL_INVERT",
|
|
208
|
+
"COMPUTER_USE_LINUX_SCREENSHOT_BACKEND",
|
|
209
|
+
"CU_DISABLE_ABS_POINTER",
|
|
210
|
+
]);
|
|
211
|
+
return Object.fromEntries(
|
|
212
|
+
Object.entries(process.env).filter(
|
|
213
|
+
(entry): entry is [string, string] =>
|
|
214
|
+
typeof entry[1] === "string" &&
|
|
215
|
+
(allowed.has(entry[0]) || entry[0].startsWith("LC_")),
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function defaultFindBinary(): BinaryLaunch | null {
|
|
221
|
+
const env = runtimeEnvironment();
|
|
222
|
+
const override = process.env.COMPUTER_USE_LINUX_BIN?.trim();
|
|
223
|
+
if (override && executable(override)) {
|
|
224
|
+
return { binaryPath: override, env };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const bundledBinary = join(
|
|
228
|
+
__dirname,
|
|
229
|
+
"..",
|
|
230
|
+
"..",
|
|
231
|
+
"npm",
|
|
232
|
+
"bin",
|
|
233
|
+
`computer-use-linux-${process.platform}-${process.arch}`,
|
|
234
|
+
);
|
|
235
|
+
if (executable(bundledBinary)) {
|
|
236
|
+
const cosmicHelper = join(
|
|
67
237
|
__dirname,
|
|
68
238
|
"..",
|
|
69
239
|
"..",
|
|
70
240
|
"npm",
|
|
71
241
|
"bin",
|
|
72
|
-
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
];
|
|
77
|
-
for (const candidate of candidates) {
|
|
78
|
-
const resolved = join(candidate);
|
|
79
|
-
if (existsSync(resolved)) {
|
|
80
|
-
_binaryPath = resolved;
|
|
81
|
-
return resolved;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// 3) PATH
|
|
86
|
-
const pathDirs = (env.PATH || "").split(":");
|
|
87
|
-
for (const dir of pathDirs) {
|
|
88
|
-
const candidate = join(dir, "computer-use-linux");
|
|
89
|
-
try {
|
|
90
|
-
if (existsSync(candidate)) {
|
|
91
|
-
accessSync(candidate, constants.X_OK);
|
|
92
|
-
_binaryPath = candidate;
|
|
93
|
-
return candidate;
|
|
94
|
-
}
|
|
95
|
-
} catch {
|
|
96
|
-
// Skip inaccessible or non-executable entries
|
|
242
|
+
"computer-use-linux-cosmic",
|
|
243
|
+
);
|
|
244
|
+
if (!env.COMPUTER_USE_LINUX_COSMIC_HELPER && executable(cosmicHelper)) {
|
|
245
|
+
env.COMPUTER_USE_LINUX_COSMIC_HELPER = cosmicHelper;
|
|
97
246
|
}
|
|
247
|
+
return { binaryPath: bundledBinary, env };
|
|
98
248
|
}
|
|
99
249
|
|
|
100
|
-
_binaryPath = null;
|
|
101
250
|
return null;
|
|
102
251
|
}
|
|
103
252
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
253
|
+
function defaultLoadClientModule(): NativeMcpClientModule {
|
|
254
|
+
return require("./mcp-client.bundle.cjs") as NativeMcpClientModule;
|
|
255
|
+
}
|
|
107
256
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
lifecycle?: string;
|
|
257
|
+
function requestTimeoutMs(): number {
|
|
258
|
+
const value = Number(process.env.COMPUTER_USE_LINUX_TIMEOUT_MS ?? "60000");
|
|
259
|
+
return Number.isFinite(value) && value > 0 ? value : 60_000;
|
|
112
260
|
}
|
|
113
261
|
|
|
114
|
-
|
|
115
|
-
|
|
262
|
+
function toolParameters(schema: Record<string, unknown>): TSchema {
|
|
263
|
+
const unsafe = (
|
|
264
|
+
Type as unknown as {
|
|
265
|
+
Unsafe?: (value: Record<string, unknown>) => TSchema;
|
|
266
|
+
}
|
|
267
|
+
).Unsafe;
|
|
268
|
+
return unsafe ? unsafe(schema) : (schema as TSchema);
|
|
116
269
|
}
|
|
117
270
|
|
|
118
|
-
function
|
|
119
|
-
|
|
271
|
+
function nativeToolDescription(tool: GeneratedMcpToolDefinition): string {
|
|
272
|
+
if (tool.annotations.destructiveHint === true) {
|
|
273
|
+
return (
|
|
274
|
+
`${tool.description} This tool can trigger actions in the live desktop; ` +
|
|
275
|
+
"obtain user approval before submitting, deleting, sending, purchasing, or overwriting."
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (tool.annotations.readOnlyHint === false) {
|
|
279
|
+
return `${tool.description} This tool changes local desktop state.`;
|
|
280
|
+
}
|
|
281
|
+
return tool.description;
|
|
120
282
|
}
|
|
121
283
|
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// so the caller knows not to overwrite it.
|
|
126
|
-
if (!existsSync(path)) return {};
|
|
284
|
+
function tokens(value: string): string[] {
|
|
285
|
+
return value.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
286
|
+
}
|
|
127
287
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
288
|
+
function searchTools(query: string): GeneratedMcpToolDefinition[] {
|
|
289
|
+
const normalized = query.trim().toLowerCase();
|
|
290
|
+
const queryTokens = tokens(normalized);
|
|
291
|
+
return AVAILABLE_MCP_TOOLS.map((tool) => {
|
|
292
|
+
const name = tool.name.toLowerCase();
|
|
293
|
+
const description = tool.description.toLowerCase();
|
|
294
|
+
const aliases = (TOOL_ALIASES[tool.name] ?? []).join(" ").toLowerCase();
|
|
295
|
+
let score = 0;
|
|
296
|
+
if (name === normalized) score += 100;
|
|
297
|
+
if (name.includes(normalized)) score += 40;
|
|
298
|
+
for (const token of queryTokens) {
|
|
299
|
+
if (name.includes(token)) score += 12;
|
|
300
|
+
if (aliases.includes(token)) score += 8;
|
|
301
|
+
if (description.includes(token)) score += 3;
|
|
138
302
|
}
|
|
139
|
-
return
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
303
|
+
return { tool, score };
|
|
304
|
+
})
|
|
305
|
+
.filter(({ score }) => score > 0)
|
|
306
|
+
.sort(
|
|
307
|
+
(left, right) =>
|
|
308
|
+
right.score - left.score ||
|
|
309
|
+
left.tool.name.localeCompare(right.tool.name),
|
|
310
|
+
)
|
|
311
|
+
.slice(0, 8)
|
|
312
|
+
.map(({ tool }) => tool);
|
|
147
313
|
}
|
|
148
314
|
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
315
|
+
function selectTools(
|
|
316
|
+
tools: string[] | undefined,
|
|
317
|
+
query: string | undefined,
|
|
318
|
+
): GeneratedMcpToolDefinition[] {
|
|
319
|
+
const selected = new Map<string, GeneratedMcpToolDefinition>();
|
|
320
|
+
for (const name of tools ?? []) {
|
|
321
|
+
const tool = TOOL_BY_ORIGINAL_NAME.get(name);
|
|
322
|
+
if (tool) selected.set(name, tool);
|
|
323
|
+
}
|
|
324
|
+
if (query?.trim()) {
|
|
325
|
+
for (const tool of searchTools(query)) {
|
|
326
|
+
selected.set(tool.name, tool);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (selected.size === 0 && !query?.trim() && !(tools?.length)) {
|
|
330
|
+
for (const name of DEFAULT_TOOLS) {
|
|
331
|
+
const tool = TOOL_BY_ORIGINAL_NAME.get(name);
|
|
332
|
+
if (tool) selected.set(name, tool);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return [...selected.values()];
|
|
152
336
|
}
|
|
153
337
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
*/
|
|
160
|
-
type EnsureResult = "updated" | "unchanged" | "failed";
|
|
161
|
-
|
|
162
|
-
function ensureServerEntry(
|
|
163
|
-
configPath: string,
|
|
164
|
-
binaryPath: string,
|
|
165
|
-
): EnsureResult {
|
|
166
|
-
const config = readMcpConfig(configPath);
|
|
167
|
-
|
|
168
|
-
// If config exists but can't be read, don't overwrite it
|
|
169
|
-
if (config === null) return "failed";
|
|
170
|
-
|
|
171
|
-
// Validate that mcpServers is a plain object before mutating
|
|
172
|
-
const servers =
|
|
173
|
-
config.mcpServers != null && isRecord(config.mcpServers)
|
|
174
|
-
? (config.mcpServers as Record<string, McpServerEntry>)
|
|
175
|
-
: {};
|
|
176
|
-
|
|
177
|
-
const existing = servers[MCP_SERVER_NAME];
|
|
178
|
-
|
|
179
|
-
// Check if entry already exists and matches
|
|
180
|
-
if (
|
|
181
|
-
existing &&
|
|
182
|
-
existing.command === binaryPath &&
|
|
183
|
-
existing.args?.length === 1 &&
|
|
184
|
-
existing.args[0] === "mcp"
|
|
185
|
-
) {
|
|
186
|
-
return "unchanged"; // No change needed
|
|
338
|
+
function renderJson(value: unknown): string {
|
|
339
|
+
try {
|
|
340
|
+
return JSON.stringify(value, null, 2);
|
|
341
|
+
} catch {
|
|
342
|
+
return String(value);
|
|
187
343
|
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function estimatedBase64Bytes(data: string): number {
|
|
347
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
348
|
+
return Math.max(0, Math.floor((data.length * 3) / 4) - padding);
|
|
349
|
+
}
|
|
188
350
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
351
|
+
function convertMcpResult(result: McpCallToolResult): PiContent[] {
|
|
352
|
+
const converted: PiContent[] = [];
|
|
353
|
+
let remainingText = MAX_RESULT_TEXT_CHARS - TRUNCATION_NOTICE.length;
|
|
354
|
+
let imageBytes = 0;
|
|
355
|
+
let imageCount = 0;
|
|
356
|
+
let truncated = false;
|
|
357
|
+
const appendText = (text: string) => {
|
|
358
|
+
if (remainingText <= 0) {
|
|
359
|
+
if (text.length > 0) truncated = true;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const keep = Math.min(text.length, remainingText);
|
|
363
|
+
if (keep > 0) {
|
|
364
|
+
converted.push({ type: "text", text: text.slice(0, keep) });
|
|
365
|
+
remainingText -= keep;
|
|
366
|
+
}
|
|
367
|
+
if (keep < text.length) truncated = true;
|
|
368
|
+
};
|
|
369
|
+
const appendImage = (data: string, mimeType: string) => {
|
|
370
|
+
const bytes = estimatedBase64Bytes(data);
|
|
371
|
+
if (
|
|
372
|
+
imageCount >= MAX_RESULT_IMAGES ||
|
|
373
|
+
imageBytes + bytes > MAX_RESULT_IMAGE_BYTES
|
|
374
|
+
) {
|
|
375
|
+
truncated = true;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
converted.push({ type: "image", data, mimeType });
|
|
379
|
+
imageBytes += bytes;
|
|
380
|
+
imageCount += 1;
|
|
193
381
|
};
|
|
194
382
|
|
|
383
|
+
for (const block of result.content ?? []) {
|
|
384
|
+
if (!isRecord(block) || typeof block.type !== "string") {
|
|
385
|
+
appendText(renderJson(block));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
389
|
+
appendText(block.text);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (
|
|
393
|
+
block.type === "image" &&
|
|
394
|
+
typeof block.data === "string" &&
|
|
395
|
+
typeof block.mimeType === "string"
|
|
396
|
+
) {
|
|
397
|
+
appendImage(block.data, block.mimeType);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (block.type === "resource" && isRecord(block.resource)) {
|
|
401
|
+
const resource = block.resource;
|
|
402
|
+
if (typeof resource.text === "string") {
|
|
403
|
+
appendText(resource.text);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (
|
|
407
|
+
typeof resource.blob === "string" &&
|
|
408
|
+
typeof resource.mimeType === "string" &&
|
|
409
|
+
resource.mimeType.startsWith("image/")
|
|
410
|
+
) {
|
|
411
|
+
appendImage(resource.blob, resource.mimeType);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
appendText(renderJson(block));
|
|
416
|
+
}
|
|
417
|
+
if (converted.length === 0 && result.structuredContent !== undefined) {
|
|
418
|
+
appendText(renderJson(result.structuredContent));
|
|
419
|
+
}
|
|
420
|
+
if (truncated) {
|
|
421
|
+
converted.push({ type: "text", text: TRUNCATION_NOTICE });
|
|
422
|
+
}
|
|
423
|
+
if (converted.length === 0) {
|
|
424
|
+
converted.push({ type: "text", text: "(empty result)" });
|
|
425
|
+
}
|
|
426
|
+
return converted;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function legacyConfigPath(): string {
|
|
430
|
+
const agentDir =
|
|
431
|
+
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
|
|
432
|
+
return join(agentDir, "mcp.json");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function hasLegacyRegistration(): boolean {
|
|
436
|
+
const path = legacyConfigPath();
|
|
437
|
+
if (!existsSync(path)) return false;
|
|
195
438
|
try {
|
|
196
|
-
|
|
197
|
-
return
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
error instanceof Error ? error.message : String(error),
|
|
439
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
440
|
+
return (
|
|
441
|
+
isRecord(config) &&
|
|
442
|
+
isRecord(config.mcpServers) &&
|
|
443
|
+
isRecord(config.mcpServers["computer-use-linux"])
|
|
202
444
|
);
|
|
203
|
-
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
204
447
|
}
|
|
205
448
|
}
|
|
206
449
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
ctx.ui.notify?.(
|
|
219
|
-
`${PACKAGE_NAME}: computer-use-linux binary not found. ` +
|
|
220
|
-
"Install it with 'npm install -g @agent-sh/computer-use-linux' " +
|
|
221
|
-
"or set COMPUTER_USE_LINUX_BIN.",
|
|
222
|
-
"warning",
|
|
223
|
-
);
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
450
|
+
function isNativeToolResult(
|
|
451
|
+
event: ToolResultEvent,
|
|
452
|
+
): event is ToolResultEvent & {
|
|
453
|
+
details: Record<string, unknown> & { computerUseLinux: true };
|
|
454
|
+
} {
|
|
455
|
+
return (
|
|
456
|
+
event.toolName.startsWith(TOOL_PREFIX) &&
|
|
457
|
+
isRecord(event.details) &&
|
|
458
|
+
event.details.computerUseLinux === true
|
|
459
|
+
);
|
|
460
|
+
}
|
|
226
461
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
462
|
+
function restoredNativeTools(ctx: ExtensionContext): string[] {
|
|
463
|
+
const entries = ctx.sessionManager.getBranch();
|
|
464
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
465
|
+
const entry = entries[index];
|
|
466
|
+
if (
|
|
467
|
+
entry?.type !== "custom" ||
|
|
468
|
+
entry.customType !== ACTIVE_TOOLS_ENTRY ||
|
|
469
|
+
!isRecord(entry.data) ||
|
|
470
|
+
!Array.isArray(entry.data.tools)
|
|
471
|
+
) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
return entry.data.tools.filter(
|
|
475
|
+
(name): name is string =>
|
|
476
|
+
typeof name === "string" && NATIVE_TOOL_NAME_SET.has(name),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return [];
|
|
480
|
+
}
|
|
230
481
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
482
|
+
export function createComputerUseLinuxExtension(
|
|
483
|
+
dependencies: ExtensionDependencies = {},
|
|
484
|
+
) {
|
|
485
|
+
const findBinary = dependencies.findBinary ?? defaultFindBinary;
|
|
486
|
+
const loadClientModule =
|
|
487
|
+
dependencies.loadClientModule ?? defaultLoadClientModule;
|
|
488
|
+
|
|
489
|
+
return function computerUseLinuxExtension(pi: ExtensionAPI) {
|
|
490
|
+
let launch: BinaryLaunch | undefined;
|
|
491
|
+
let client: NativeMcpClient | undefined;
|
|
492
|
+
let enabledNativeTools = new Set<string>();
|
|
493
|
+
|
|
494
|
+
const resolveLaunch = () => {
|
|
495
|
+
if (launch) return launch;
|
|
496
|
+
const resolved = findBinary();
|
|
497
|
+
if (resolved) launch = resolved;
|
|
498
|
+
return resolved;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const getClient = () => {
|
|
502
|
+
if (client) return client;
|
|
503
|
+
const resolved = resolveLaunch();
|
|
504
|
+
if (!resolved) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
"computer-use-linux binary was not found. Reinstall " +
|
|
507
|
+
`${PACKAGE_NAME} or set COMPUTER_USE_LINUX_BIN.`,
|
|
236
508
|
);
|
|
237
509
|
}
|
|
510
|
+
const { ComputerUseMcpClient } = loadClientModule();
|
|
511
|
+
client = new ComputerUseMcpClient({
|
|
512
|
+
binaryPath: resolved.binaryPath,
|
|
513
|
+
clientVersion: GENERATED_SERVER_VERSION,
|
|
514
|
+
env: resolved.env,
|
|
515
|
+
expectedCatalogHash: SHELL_ENABLED
|
|
516
|
+
? GENERATED_SHELL_TOOL_CATALOG_HASH
|
|
517
|
+
: GENERATED_TOOL_CATALOG_HASH,
|
|
518
|
+
expectedServerVersion: GENERATED_SERVER_VERSION,
|
|
519
|
+
requestTimeoutMs: requestTimeoutMs(),
|
|
520
|
+
});
|
|
521
|
+
return client;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
for (const tool of AVAILABLE_MCP_TOOLS) {
|
|
525
|
+
const nativeName = `${TOOL_PREFIX}${tool.name}`;
|
|
526
|
+
pi.registerTool({
|
|
527
|
+
name: nativeName,
|
|
528
|
+
label: `Computer Use: ${tool.name}`,
|
|
529
|
+
description: nativeToolDescription(tool),
|
|
530
|
+
parameters: toolParameters(tool.inputSchema),
|
|
531
|
+
executionMode: "sequential",
|
|
532
|
+
async execute(_toolCallId, params, signal) {
|
|
533
|
+
const result = await getClient().callTool(
|
|
534
|
+
tool.name,
|
|
535
|
+
params as Record<string, unknown>,
|
|
536
|
+
signal,
|
|
537
|
+
);
|
|
538
|
+
return {
|
|
539
|
+
content: convertMcpResult(result),
|
|
540
|
+
details: {
|
|
541
|
+
computerUseLinux: true,
|
|
542
|
+
mcpIsError: result.isError === true,
|
|
543
|
+
tool: tool.name,
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
}
|
|
238
549
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
550
|
+
pi.registerTool({
|
|
551
|
+
name: LOADER_TOOL_NAME,
|
|
552
|
+
label: "Computer Use Tools",
|
|
553
|
+
description:
|
|
554
|
+
"Enable native Linux desktop observation/control tools starting next turn for this session. " +
|
|
555
|
+
"Choose exact tool names or search by capability; no desktop process starts until an enabled tool is called.",
|
|
556
|
+
promptSnippet:
|
|
557
|
+
"Enable Linux desktop tools only when the task needs local GUI observation or control",
|
|
558
|
+
promptGuidelines: [
|
|
559
|
+
"Use computer_use_linux_tools before attempting local Linux GUI observation or control.",
|
|
560
|
+
"After enabling Computer Use tools, begin with computer_use_linux_get_app_state; use computer_use_linux_list_windows or computer_use_linux_focused_window before targeted keyboard input, and re-observe after the UI changes.",
|
|
561
|
+
],
|
|
562
|
+
parameters: LoaderParameters,
|
|
563
|
+
async execute(_toolCallId, params) {
|
|
564
|
+
const selected = selectTools(params.tools, params.query);
|
|
565
|
+
if (selected.length === 0) {
|
|
566
|
+
return {
|
|
567
|
+
content: [
|
|
568
|
+
{
|
|
569
|
+
type: "text",
|
|
570
|
+
text:
|
|
571
|
+
"No matching Computer Use tools. Available names: " +
|
|
572
|
+
ORIGINAL_TOOL_NAMES.join(", "),
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
details: { added: [], matches: [] },
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const active = pi.getActiveTools();
|
|
580
|
+
const additions = selected
|
|
581
|
+
.map((tool) => `${TOOL_PREFIX}${tool.name}`)
|
|
582
|
+
.filter((name) => !active.includes(name));
|
|
583
|
+
for (const tool of selected) {
|
|
584
|
+
enabledNativeTools.add(`${TOOL_PREFIX}${tool.name}`);
|
|
585
|
+
}
|
|
586
|
+
if (additions.length > 0) {
|
|
587
|
+
pi.setActiveTools([...active, ...additions]);
|
|
588
|
+
}
|
|
589
|
+
pi.appendEntry(ACTIVE_TOOLS_ENTRY, {
|
|
590
|
+
tools: [...enabledNativeTools].sort(),
|
|
591
|
+
});
|
|
592
|
+
const lines = selected.map(
|
|
593
|
+
(tool) => `- ${tool.name}: ${tool.description}`,
|
|
244
594
|
);
|
|
595
|
+
return {
|
|
596
|
+
content: [
|
|
597
|
+
{
|
|
598
|
+
type: "text",
|
|
599
|
+
text:
|
|
600
|
+
`Enabled ${selected.length} Computer Use tool(s) starting next turn for this session:\n` +
|
|
601
|
+
lines.join("\n"),
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
details: {
|
|
605
|
+
added: additions,
|
|
606
|
+
matches: selected.map((tool) => tool.name),
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
pi.on("session_start", (event, ctx) => {
|
|
613
|
+
enabledNativeTools = new Set(restoredNativeTools(ctx));
|
|
614
|
+
const active = pi
|
|
615
|
+
.getActiveTools()
|
|
616
|
+
.filter((name) => !NATIVE_TOOL_NAME_SET.has(name));
|
|
617
|
+
active.push(...enabledNativeTools);
|
|
618
|
+
if (!active.includes(LOADER_TOOL_NAME)) {
|
|
619
|
+
active.push(LOADER_TOOL_NAME);
|
|
245
620
|
}
|
|
621
|
+
pi.setActiveTools(active);
|
|
246
622
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
`${PACKAGE_NAME}: pi-mcp-adapter not detected. ` +
|
|
251
|
-
"Install it with 'pi install npm:pi-mcp-adapter' then /reload.",
|
|
623
|
+
if (!resolveLaunch() && ctx.hasUI) {
|
|
624
|
+
ctx.ui.notify(
|
|
625
|
+
`${PACKAGE_NAME}: binary not found; reinstall the package or set COMPUTER_USE_LINUX_BIN.`,
|
|
252
626
|
"warning",
|
|
253
627
|
);
|
|
254
628
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
629
|
+
if (hasLegacyRegistration() && ctx.hasUI) {
|
|
630
|
+
ctx.ui.notify(
|
|
631
|
+
`${PACKAGE_NAME}: native Pi tools are active. A legacy computer-use-linux entry still exists in ${legacyConfigPath()}; remove that entry if it is no longer needed by pi-mcp-adapter.`,
|
|
632
|
+
"info",
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
pi.on("tool_result", (event) => {
|
|
638
|
+
if (
|
|
639
|
+
isNativeToolResult(event) &&
|
|
640
|
+
event.details.mcpIsError === true
|
|
641
|
+
) {
|
|
642
|
+
return { isError: true };
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
pi.on("session_shutdown", async () => {
|
|
647
|
+
const current = client;
|
|
648
|
+
client = undefined;
|
|
649
|
+
await current?.close();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
};
|
|
262
653
|
}
|
|
654
|
+
|
|
655
|
+
export default createComputerUseLinuxExtension();
|