@agent-sh/computer-use-linux 0.4.10 → 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.
@@ -1,262 +1,655 @@
1
1
  /**
2
- * Pi coding agent extension for computer-use-linux.
2
+ * Native Pi integration for computer-use-linux.
3
3
  *
4
- * Discovers the computer-use-linux binary and registers it as an MCP server
5
- * for pi-mcp-adapter by writing to ~/.pi/agent/mcp.json.
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 { ExtensionAPI } from "@earendil-works/pi-coding-agent";
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 { dirname, join } from "node:path";
21
- import { env } from "node:process";
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
- // Constants
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
- const PACKAGE_NAME = "@agent-sh/computer-use-linux";
28
- const MCP_SERVER_NAME = "computer-use-linux";
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
- * Path to pi's agent-level MCP config. pi-mcp-adapter reads from this file
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
- // Binary discovery
44
- // ---------------------------------------------------------------------------
89
+ interface BinaryLaunch {
90
+ binaryPath: string;
91
+ env: Record<string, string>;
92
+ }
45
93
 
46
- /**
47
- * Find the computer-use-linux binary. Cached after first successful lookup.
48
- */
49
- let _binaryPath: string | null | undefined;
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 findBinary(): string | null {
52
- if (_binaryPath !== undefined) return _binaryPath;
151
+ function isRecord(value: unknown): value is Record<string, unknown> {
152
+ return typeof value === "object" && value !== null && !Array.isArray(value);
153
+ }
53
154
 
54
- // 1) Environment variable override
55
- const fromEnv = env.COMPUTER_USE_LINUX_BIN;
56
- if (fromEnv && existsSync(fromEnv)) {
57
- _binaryPath = fromEnv;
58
- return fromEnv;
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
- // 2) Bundled by the npm wrapper's postinstall
62
- // The npm wrapper downloads binaries to npm/bin/ next to the JS wrapper.
63
- // We derive the path relative to our own installed location.
64
- const candidates = [
65
- // Bundled binary for this platform
66
- join(
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
- `computer-use-linux-linux-${process.arch}`,
73
- ),
74
- // Direct npm global install
75
- join(__dirname, "..", "..", "npm", "bin", "computer-use-linux"),
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
- // MCP config management
106
- // ---------------------------------------------------------------------------
253
+ function defaultLoadClientModule(): NativeMcpClientModule {
254
+ return require("./mcp-client.bundle.cjs") as NativeMcpClientModule;
255
+ }
107
256
 
108
- interface McpServerEntry {
109
- command: string;
110
- args: string[];
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
- interface McpConfig {
115
- mcpServers?: Record<string, McpServerEntry>;
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 isRecord(value: unknown): value is Record<string, unknown> {
119
- return typeof value === "object" && value !== null && !Array.isArray(value);
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 readMcpConfig(path: string): McpConfig | null {
123
- // Only return empty if the file genuinely doesn't exist.
124
- // If the file exists but can't be read or parsed, return null
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
- try {
129
- const raw = readFileSync(path, "utf-8");
130
- const parsed = JSON.parse(raw);
131
- if (!isRecord(parsed)) {
132
- console.error(
133
- `${PACKAGE_NAME}: MCP config at ${path} contains a ${typeof parsed}` +
134
- (Array.isArray(parsed) ? " (array)" : "") +
135
- ", expected a JSON object. Skipping.",
136
- );
137
- return null;
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 parsed as McpConfig;
140
- } catch (error) {
141
- console.error(
142
- `${PACKAGE_NAME}: failed to read existing MCP config at ${path}:`,
143
- error instanceof Error ? error.message : String(error),
144
- );
145
- return null;
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 writeMcpConfig(path: string, config: McpConfig): void {
150
- mkdirSync(dirname(path), { recursive: true });
151
- writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf-8");
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
- * Result of ensuring the computer-use-linux entry in the MCP config.
156
- * - "updated": entry was written or updated successfully
157
- * - "unchanged": entry already matches, no write needed
158
- * - "failed": config could not be read or written
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
- // Add or update the entry
190
- servers[MCP_SERVER_NAME] = {
191
- command: binaryPath,
192
- args: ["mcp"],
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
- writeMcpConfig(configPath, { ...config, mcpServers: servers });
197
- return "updated"; // Config was updated
198
- } catch (error) {
199
- console.error(
200
- `${PACKAGE_NAME}: failed to write MCP config to ${configPath}:`,
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
- return "failed"; // Write failed
445
+ } catch {
446
+ return false;
204
447
  }
205
448
  }
206
449
 
207
- // ---------------------------------------------------------------------------
208
- // Extension entry point
209
- // ---------------------------------------------------------------------------
210
-
211
- export default function (pi: ExtensionAPI) {
212
- // Resolve binary path eagerly (before session_start) so it's cached.
213
- const binaryPath = findBinary();
214
-
215
- pi.on("session_start", async (_event, ctx) => {
216
- try {
217
- if (!binaryPath) {
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
- // Write/update the MCP config that pi-mcp-adapter reads
228
- const configPath = getPiAgentMcpConfigPath();
229
- const result = ensureServerEntry(configPath, binaryPath);
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
- if (result === "updated" && ctx.hasUI) {
232
- ctx.ui.notify?.(
233
- `${PACKAGE_NAME}: MCP server configured at ${configPath}. ` +
234
- "Run /reload if pi-mcp-adapter is already installed.",
235
- "info",
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
- if (result === "failed" && ctx.hasUI) {
240
- ctx.ui.notify?.(
241
- `${PACKAGE_NAME}: failed to configure MCP server at ${configPath}. ` +
242
- "Check the console logs and ensure the file is writable.",
243
- "error",
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
- // Check that pi-mcp-adapter is available (its tools are registered)
248
- if (!pi.getAllTools().some((t) => t.name === "mcp")) {
249
- ctx.ui.notify?.(
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
- } catch (error) {
256
- console.error(
257
- `${PACKAGE_NAME}: unexpected error in session_start handler:`,
258
- error instanceof Error ? error.message : String(error),
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();