@minhspark/codex-mcp-bridge 1.27.1 → 2.0.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/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +50 -1
- package/docs/daily-handoffs.md +13 -24
- package/docs/desktop-handoff-design.md +3 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/antigravity-ipc.mjs +7 -4
- package/src/claude-bridge.mjs +14 -150
- package/src/claude-session-router.mjs +34 -10
- package/src/index.mjs +1 -1
- package/src/message-automation.mjs +1 -1
- package/src/native-relay-companion.mjs +8 -4
- package/src/native-relay-files.mjs +43 -0
- package/src/peer-protocol.mjs +0 -23
- package/src/platform.mjs +0 -20
- package/src/project-onboarding.mjs +1 -1
- package/src/claude-composer-submit.mjs +0 -102
- package/src/claude-composer-submit.ps1 +0 -211
- package/src/claude-session-creation.mjs +0 -306
|
@@ -35,7 +35,7 @@ import { createRuntimeState } from "./runtime-state.mjs";
|
|
|
35
35
|
import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
|
|
36
36
|
import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
|
|
37
37
|
import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
38
|
-
import { removeRelayFile, relayFileIdentity, verifyRelaySocketMode } from "./native-relay-files.mjs";
|
|
38
|
+
import { releasePrivateSocketLinks, removeRelayFile, relayFileIdentity, staleSocketIdentity, verifyRelaySocketMode } from "./native-relay-files.mjs";
|
|
39
39
|
|
|
40
40
|
import { NativeCreationReceipts, creationIdValid } from "./native-creation-receipts.mjs";
|
|
41
41
|
import { registerRemoteCodexTools } from "./remote-codex-tools.mjs";
|
|
@@ -43,7 +43,7 @@ import { remoteCodexSocketPath, remoteNativeAdapter, remoteRequestResponse } fro
|
|
|
43
43
|
|
|
44
44
|
exitForVersionRequest(import.meta.url);
|
|
45
45
|
|
|
46
|
-
const VERSION = "
|
|
46
|
+
const VERSION = "2.0.0";
|
|
47
47
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
48
48
|
|
|
49
49
|
function errorResponse(code, message, sent, details) {
|
|
@@ -565,9 +565,13 @@ export class RelaySocketServer {
|
|
|
565
565
|
try { fs.linkSync(privatePath, this.socketPath); break; }
|
|
566
566
|
catch (error) {
|
|
567
567
|
if (error.code !== "EEXIST") throw error;
|
|
568
|
-
if (!replaceStale || attempt
|
|
568
|
+
if (!replaceStale || attempt) throw new Error(`another native relay companion already owns ${this.socketPath}`);
|
|
569
|
+
const stale = staleSocketIdentity(this.socketPath);
|
|
570
|
+
if (stale === null) continue;
|
|
571
|
+
if (await this.#socketIsLive()) throw new Error(`another native relay companion already owns ${this.socketPath}`);
|
|
569
572
|
this.log(`removing the stale relay socket left at ${this.socketPath}`);
|
|
570
|
-
|
|
573
|
+
releasePrivateSocketLinks(this.socketPath, stale);
|
|
574
|
+
removeRelayFile(this.socketPath, { expected: stale });
|
|
571
575
|
}
|
|
572
576
|
}
|
|
573
577
|
const identity = fs.lstatSync(this.socketPath, { bigint: true });
|
|
@@ -17,6 +17,49 @@ export function relayFileIdentity(target, kind, expected) {
|
|
|
17
17
|
return current;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const PRIVATE_SOCKET_LINK = /^\.[0-9a-f]{12}\.sock$/;
|
|
21
|
+
|
|
22
|
+
const ownedSocket = (stat) => stat.isSocket() && (typeof process.getuid !== "function" || stat.uid === BigInt(process.getuid()));
|
|
23
|
+
|
|
24
|
+
export function staleSocketIdentity(target) {
|
|
25
|
+
let root;
|
|
26
|
+
try { root = fs.realpathSync.native(path.dirname(target)); }
|
|
27
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
28
|
+
const file = path.resolve(root, path.basename(target));
|
|
29
|
+
if (!file.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`)) throw new Error("Relay file escaped its directory");
|
|
30
|
+
let current;
|
|
31
|
+
try { current = fs.lstatSync(file, { bigint: true }); }
|
|
32
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
33
|
+
if (!ownedSocket(current)) throw new Error(`Unsafe relay socket file: ${target}`);
|
|
34
|
+
return { dev: current.dev, ino: current.ino };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function releasePrivateSocketLinks(target, expected) {
|
|
38
|
+
let root;
|
|
39
|
+
try { root = fs.realpathSync.native(path.dirname(target)); }
|
|
40
|
+
catch (error) { if (error.code === "ENOENT") return 0; throw error; }
|
|
41
|
+
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
|
42
|
+
const file = path.resolve(root, path.basename(target));
|
|
43
|
+
if (!file.startsWith(prefix)) throw new Error("Relay file escaped its directory");
|
|
44
|
+
let current;
|
|
45
|
+
try { current = fs.lstatSync(file, { bigint: true }); }
|
|
46
|
+
catch (error) { if (error.code === "ENOENT") return 0; throw error; }
|
|
47
|
+
if (current.nlink === 1n || !ownedSocket(current) || current.dev !== expected.dev || current.ino !== expected.ino) return 0;
|
|
48
|
+
let released = 0;
|
|
49
|
+
for (const name of fs.readdirSync(root)) {
|
|
50
|
+
if (!PRIVATE_SOCKET_LINK.test(name)) continue;
|
|
51
|
+
const link = path.resolve(root, name);
|
|
52
|
+
if (!link.startsWith(prefix)) continue;
|
|
53
|
+
let stat;
|
|
54
|
+
try { stat = fs.lstatSync(link, { bigint: true }); }
|
|
55
|
+
catch (error) { if (error.code === "ENOENT") continue; throw error; }
|
|
56
|
+
if (!ownedSocket(stat) || stat.dev !== current.dev || stat.ino !== current.ino) continue;
|
|
57
|
+
fs.unlinkSync(link);
|
|
58
|
+
released += 1;
|
|
59
|
+
}
|
|
60
|
+
return released;
|
|
61
|
+
}
|
|
62
|
+
|
|
20
63
|
export function removeRelayFile(target, { kind = "socket", directory = path.dirname(target), expected } = {}) {
|
|
21
64
|
let root;
|
|
22
65
|
try { root = fs.realpathSync.native(directory); }
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -324,29 +324,6 @@ export function readTranscript(sessionId, cwd, limit = 10) {
|
|
|
324
324
|
return { file, messages: messages.slice(-limit) };
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
export function readInitialTranscriptMessage(sessionId, cwd) {
|
|
328
|
-
const file = findTranscriptFile(sessionId, cwd);
|
|
329
|
-
if (!file || !fs.existsSync(file)) return { file, messages: [] };
|
|
330
|
-
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
331
|
-
try {
|
|
332
|
-
const stat = fs.fstatSync(descriptor);
|
|
333
|
-
if (!stat.isFile()) return { file, messages: [] };
|
|
334
|
-
const buffer = Buffer.alloc(Math.min(stat.size, 1024 * 1024));
|
|
335
|
-
const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
336
|
-
const text = buffer.subarray(0, bytes).toString("utf8");
|
|
337
|
-
for (const line of text.slice(0, text.lastIndexOf("\n") + 1).split("\n")) {
|
|
338
|
-
if (!line.trim()) continue;
|
|
339
|
-
let entry;
|
|
340
|
-
try { entry = JSON.parse(line); } catch { return { file, messages: [] }; }
|
|
341
|
-
if (entry.isMeta || entry.isSidechain || entry.message?.role !== "user") continue;
|
|
342
|
-
const content = entry.message.content;
|
|
343
|
-
const initial = (Array.isArray(content) ? content.filter((part) => part?.type === "text").map((part) => part.text ?? "").join("\n") : String(content ?? "")).trim();
|
|
344
|
-
return { file, messages: initial ? [{ role: "user", text: initial }] : [] };
|
|
345
|
-
}
|
|
346
|
-
return { file, messages: [] };
|
|
347
|
-
} finally { fs.closeSync(descriptor); }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
327
|
/**
|
|
351
328
|
* Claude Code records an injected peer message in one of two shapes. An idle
|
|
352
329
|
* recipient starts a new turn with a user entry whose uuid is the message id
|
package/src/platform.mjs
CHANGED
|
@@ -561,23 +561,3 @@ export async function openThreadInCodexApp(threadId, { activate = true, platform
|
|
|
561
561
|
await run("open", args, { timeout: 10000 });
|
|
562
562
|
return url;
|
|
563
563
|
}
|
|
564
|
-
|
|
565
|
-
export async function openClaudeCodeComposer(url, { platform = process.platform, env = process.env, run = execFileAsync } = {}) {
|
|
566
|
-
const parsed = new URL(url);
|
|
567
|
-
const keys = parsed.pathname === "/continue" ? ["session"] : ["q", "folder"];
|
|
568
|
-
if (parsed.protocol !== "claude:" || parsed.hostname !== "code" || !["/new", "/continue"].includes(parsed.pathname) || parsed.username || parsed.password || parsed.port || parsed.hash
|
|
569
|
-
|| [...parsed.searchParams.keys()].some((key) => !keys.includes(key) || parsed.searchParams.getAll(key).length !== 1)
|
|
570
|
-
|| (parsed.pathname === "/continue" && !/^local_[0-9a-f-]{36}$/i.test(parsed.searchParams.get("session") ?? ""))) {
|
|
571
|
-
throw new Error("Invalid Claude Desktop Code creation link");
|
|
572
|
-
}
|
|
573
|
-
if (platform === "win32") {
|
|
574
|
-
const shell = windowsPowerShell(env);
|
|
575
|
-
const encodedUrl = Buffer.from(url, "utf8").toString("base64");
|
|
576
|
-
const script = String.raw`$ErrorActionPreference='Stop'; $creationUrl=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedUrl}')); try { Start-Process -FilePath $creationUrl -ErrorAction Stop } catch { $desktopPaths=@(Get-Process claude -ErrorAction SilentlyContinue | ForEach-Object { $_.Path } | Where-Object { $_ -match '\\(?:AnthropicClaude\\app-[^\\]+|WindowsApps\\Claude_[^\\]+\\app)\\claude\.exe$' -and (Test-Path -LiteralPath $_) } | Select-Object -Unique); if ($desktopPaths.Count -eq 0 -and $env:LOCALAPPDATA) { $desktopPaths=@(Get-ChildItem -LiteralPath (Join-Path $env:LOCALAPPDATA 'AnthropicClaude') -Directory -Filter 'app-*' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | ForEach-Object { Join-Path $_.FullName 'claude.exe' } | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1) }; if ($desktopPaths.Count -ne 1) { throw }; Start-Process -FilePath $desktopPaths[0] -ArgumentList ('"'+$creationUrl+'"') -WindowStyle Hidden -ErrorAction Stop }`;
|
|
577
|
-
await run(shell, ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { windowsHide: true, timeout: 10000 });
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
const command = platform === "darwin" ? "/usr/bin/open" : platform === "linux" ? "/usr/bin/xdg-open" : null;
|
|
581
|
-
if (!command) throw new Error("Claude Desktop Code links are unsupported on this platform");
|
|
582
|
-
await run(command, [url], { windowsHide: true, timeout: 10000 });
|
|
583
|
-
}
|
|
@@ -109,7 +109,7 @@ const LIVE_CHECKS = {
|
|
|
109
109
|
},
|
|
110
110
|
codex: {
|
|
111
111
|
senderDestination: "Each Claude delivery separately verifies the Codex sender, the recipient session, its permission class and the accounts. A project grant does not replace these checks. Check claude_bridge_status in the calling task.",
|
|
112
|
-
creationTarget: "
|
|
112
|
+
creationTarget: "The bridge does not create Claude sessions. The user opens a new session in Claude Desktop; this inspection does not open or check one.",
|
|
113
113
|
},
|
|
114
114
|
};
|
|
115
115
|
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
6
|
-
|
|
7
|
-
const execute = promisify(execFile);
|
|
8
|
-
const helperPath = fileURLToPath(new URL("./claude-composer-submit.ps1", import.meta.url));
|
|
9
|
-
|
|
10
|
-
export async function trustClaudeCodeComposer({ cwd, prompt }, options = {}) {
|
|
11
|
-
const platform = options.platform ?? process.platform;
|
|
12
|
-
if (platform !== "win32") return { status: "blocked", reason: "native_workspace_trust_unsupported", platform };
|
|
13
|
-
if (typeof cwd !== "string" || !path.win32.isAbsolute(cwd)) return { status: "blocked", reason: "absolute_project_path_required" };
|
|
14
|
-
const payload = Buffer.from(JSON.stringify({ cwd, prompt }), "utf8").toString("base64");
|
|
15
|
-
try {
|
|
16
|
-
const result = await (options.run ?? execute)("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", helperPath, "-PayloadBase64", payload, "-Action", "Trust", "-WaitMilliseconds", "8000"], { windowsHide: true, timeout: 12000, encoding: "utf8", maxBuffer: 1024 * 1024 });
|
|
17
|
-
const trusted = JSON.parse(result.stdout.trim().replace(/^\uFEFF/, ""));
|
|
18
|
-
if (!["trusted", "absent", "blocked", "uncertain"].includes(trusted?.status)) return { status: "uncertain", reason: "native_workspace_trust_outcome_unknown" };
|
|
19
|
-
return trusted;
|
|
20
|
-
} catch { return { status: "uncertain", reason: "native_workspace_trust_outcome_unknown" }; }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function assessClaudeCodeComposer(snapshot, { prompt, cwd }) {
|
|
24
|
-
const block = (reason) => ({ status: "blocked", reason });
|
|
25
|
-
if (!snapshot || !Array.isArray(snapshot.windows) || snapshot.windows.length !== 1) {
|
|
26
|
-
return block("desktop_window_ambiguous_or_missing");
|
|
27
|
-
}
|
|
28
|
-
const window = snapshot.windows[0];
|
|
29
|
-
if (window.installedDesktop !== true) return block("desktop_process_not_verified");
|
|
30
|
-
if (snapshot.dialogs?.length) return block("desktop_dialog_requires_attention");
|
|
31
|
-
if (snapshot.codeSelected !== true) return block("code_mode_not_selected");
|
|
32
|
-
if (!Array.isArray(snapshot.composers) || snapshot.composers.length !== 1) {
|
|
33
|
-
return block("composer_ambiguous_or_missing");
|
|
34
|
-
}
|
|
35
|
-
const composerText = (text) => typeof text === "string" ? text.replace(/\r\n/g, "\n").split("\n").filter((line) => line !== "").join("\n") : null;
|
|
36
|
-
if (composerText(snapshot.composers[0].text) !== composerText(prompt)) return block("composer_prompt_mismatch");
|
|
37
|
-
if (snapshot.worktreeEnabled !== false) return block("original_project_folder_not_verified");
|
|
38
|
-
if (snapshot.folderCount !== 1 || snapshot.localMode !== true) return block("project_folder_ambiguous_or_remote");
|
|
39
|
-
if (!Array.isArray(snapshot.selectedFolderPaths) || snapshot.selectedFolderPaths.length !== 1) {
|
|
40
|
-
return block("selected_project_path_not_verified");
|
|
41
|
-
}
|
|
42
|
-
const normalize = (value) => path.win32.normalize(value).replace(/[\\/]+$/, "").toLowerCase();
|
|
43
|
-
if (typeof snapshot.selectedFolderPaths[0] !== "string" || !path.win32.isAbsolute(snapshot.selectedFolderPaths[0]) || normalize(snapshot.selectedFolderPaths[0]) !== normalize(cwd)) {
|
|
44
|
-
return block("selected_project_path_mismatch");
|
|
45
|
-
}
|
|
46
|
-
if (snapshot.sendButtons?.length !== 1 || snapshot.sendButtons[0].enabled !== true || snapshot.sendButtons[0].invokable !== true) {
|
|
47
|
-
return block("send_button_ambiguous_disabled_or_unavailable");
|
|
48
|
-
}
|
|
49
|
-
return { status: "ready", reason: "exact_composer_and_project_verified", windowId: window.id };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function submitClaudeCodeComposer({ prompt, cwd, beforeSubmit }, options = {}) {
|
|
53
|
-
const platform = options.platform ?? process.platform;
|
|
54
|
-
if (typeof prompt !== "string" || !prompt.length || typeof cwd !== "string" || !cwd.length) {
|
|
55
|
-
return { status: "blocked", reason: "prompt_and_project_required" };
|
|
56
|
-
}
|
|
57
|
-
if (platform !== "win32") {
|
|
58
|
-
return { status: "blocked", reason: "native_composer_submission_unsupported", platform };
|
|
59
|
-
}
|
|
60
|
-
if (!path.win32.isAbsolute(cwd)) return { status: "blocked", reason: "absolute_project_path_required" };
|
|
61
|
-
const run = options.run ?? execute;
|
|
62
|
-
const requestedBudget = options.timeoutMs ?? 20000;
|
|
63
|
-
const budget = Number.isFinite(requestedBudget) ? Math.max(1000, Math.min(20000, requestedBudget)) : 20000;
|
|
64
|
-
const started = Date.now();
|
|
65
|
-
const payload = Buffer.from(JSON.stringify({ prompt, cwd }), "utf8").toString("base64");
|
|
66
|
-
let attempted = false;
|
|
67
|
-
try {
|
|
68
|
-
const call = async (action, windowId) => {
|
|
69
|
-
const remaining = budget - (Date.now() - started);
|
|
70
|
-
if (remaining <= 0) throw new Error("Native composer deadline exceeded");
|
|
71
|
-
const args = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", helperPath,
|
|
72
|
-
"-PayloadBase64", payload, "-Action", action, "-WaitMilliseconds", String(Math.min(8000, remaining - 250))];
|
|
73
|
-
if (windowId != null) args.push("-ExpectedWindowId", String(windowId));
|
|
74
|
-
const result = await run("powershell.exe", args, {
|
|
75
|
-
windowsHide: true, timeout: remaining, maxBuffer: 1024 * 1024, encoding: "utf8",
|
|
76
|
-
});
|
|
77
|
-
return JSON.parse(result.stdout.trim().replace(/^\uFEFF/, ""));
|
|
78
|
-
};
|
|
79
|
-
const inspection = await call("Inspect");
|
|
80
|
-
if (inspection.status === "blocked") return { ...inspection, submissionAttempted: false };
|
|
81
|
-
const assessment = assessClaudeCodeComposer(inspection.snapshot, { prompt, cwd });
|
|
82
|
-
if (assessment.status !== "ready") return { ...assessment, submissionAttempted: false };
|
|
83
|
-
if (beforeSubmit) await beforeSubmit();
|
|
84
|
-
const selection = await call("SelectFolder", assessment.windowId);
|
|
85
|
-
if (selection.status !== "selected") return { status: "blocked", reason: selection.reason ?? "native_folder_selection_not_confirmed", submissionAttempted: false };
|
|
86
|
-
await (options.sleep ?? delay)(2000);
|
|
87
|
-
if (beforeSubmit) await beforeSubmit();
|
|
88
|
-
attempted = true;
|
|
89
|
-
const result = await call("Submit", assessment.windowId);
|
|
90
|
-
if (!["submitted", "blocked", "uncertain"].includes(result.status)) {
|
|
91
|
-
return { status: "uncertain", reason: "native_submit_result_invalid", submissionAttempted: true };
|
|
92
|
-
}
|
|
93
|
-
return { ...result, submissionAttempted: result.status !== "blocked" };
|
|
94
|
-
} catch (error) {
|
|
95
|
-
return {
|
|
96
|
-
status: attempted ? "uncertain" : "blocked",
|
|
97
|
-
reason: attempted ? "native_submit_outcome_unknown" : "native_accessibility_unavailable",
|
|
98
|
-
submissionAttempted: attempted,
|
|
99
|
-
error: error.code ?? error.name ?? "Error",
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
}
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
param(
|
|
2
|
-
[Parameter(Mandatory = $true)][string]$PayloadBase64,
|
|
3
|
-
[ValidateSet('Inspect', 'SelectFolder', 'Submit', 'Trust')][string]$Action = 'Inspect',
|
|
4
|
-
[int]$WaitMilliseconds = 8000,
|
|
5
|
-
[long]$ExpectedWindowId = 0
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
$ErrorActionPreference = 'Stop'
|
|
9
|
-
$ProgressPreference = 'SilentlyContinue'
|
|
10
|
-
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
11
|
-
Add-Type -AssemblyName UIAutomationClient
|
|
12
|
-
Add-Type -AssemblyName UIAutomationTypes
|
|
13
|
-
$request = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($PayloadBase64)) | ConvertFrom-Json
|
|
14
|
-
$deadline = [DateTime]::UtcNow.AddMilliseconds([Math]::Max(0, [Math]::Min(8000, $WaitMilliseconds)))
|
|
15
|
-
$invocationStarted = $false
|
|
16
|
-
|
|
17
|
-
function Finish([string]$Status, [string]$Reason, $Snapshot = $null) {
|
|
18
|
-
$result = @{ status = $Status; reason = $Reason }
|
|
19
|
-
if ($null -ne $Snapshot) { $result.snapshot = $Snapshot }
|
|
20
|
-
$result | ConvertTo-Json -Depth 8 -Compress
|
|
21
|
-
exit 0
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function Descendants($Element) {
|
|
25
|
-
return $Element.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function Dialogs($Element) {
|
|
29
|
-
for ($attempt = 0; $attempt -lt 3; $attempt++) {
|
|
30
|
-
try {
|
|
31
|
-
@(Descendants $Element | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Window -or $_.GetCurrentPropertyValue([System.Windows.Automation.AutomationElement]::IsDialogProperty) -eq $true })
|
|
32
|
-
return
|
|
33
|
-
} catch {
|
|
34
|
-
if ($attempt -eq 2) { throw }
|
|
35
|
-
Start-Sleep -Milliseconds 150
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function TextValue($Element) {
|
|
41
|
-
$pattern = $null
|
|
42
|
-
if ($Element.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$pattern)) {
|
|
43
|
-
return ([System.Windows.Automation.ValuePattern]$pattern).Current.Value
|
|
44
|
-
}
|
|
45
|
-
if ($Element.TryGetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern, [ref]$pattern)) {
|
|
46
|
-
return ([System.Windows.Automation.TextPattern]$pattern).DocumentRange.GetText(-1)
|
|
47
|
-
}
|
|
48
|
-
return $null
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function IsInstalledDesktop($Process) {
|
|
52
|
-
try {
|
|
53
|
-
$exe = Get-Item -LiteralPath $Process.Path
|
|
54
|
-
if ($exe.VersionInfo.ProductName -ne 'Claude' -or $exe.VersionInfo.CompanyName -ne 'Anthropic') { return $false }
|
|
55
|
-
$localRoot = Join-Path $env:LOCALAPPDATA 'AnthropicClaude'
|
|
56
|
-
$root = [IO.Path]::GetFullPath($localRoot).TrimEnd('\') + '\'
|
|
57
|
-
if ($exe.FullName.StartsWith($root, [StringComparison]::OrdinalIgnoreCase) -and $exe.Directory.Name -match '^app-\d+(\.\d+)+$' -and $exe.Name -ieq 'claude.exe') { return $true }
|
|
58
|
-
foreach ($package in @(Get-AppxPackage -Name '*Claude*' -ErrorAction SilentlyContinue)) {
|
|
59
|
-
if ($package.InstallLocation -and $exe.FullName.StartsWith($package.InstallLocation.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { return $true }
|
|
60
|
-
}
|
|
61
|
-
} catch {}
|
|
62
|
-
return $false
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function PathEqual([string]$First, [string]$Second) {
|
|
66
|
-
try {
|
|
67
|
-
if ($First -notmatch '^(?:[A-Za-z]:[\\/]|\\\\[^\\]+\\[^\\]+)') { return $false }
|
|
68
|
-
return [IO.Path]::GetFullPath($First).TrimEnd('\').Equals([IO.Path]::GetFullPath($Second).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)
|
|
69
|
-
} catch { return $false }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function PromptEqual([string]$First, [string]$Second) {
|
|
73
|
-
$firstLines = @($First.Replace("`r`n", "`n").Split("`n") | Where-Object { $_ -cne '' })
|
|
74
|
-
$secondLines = @($Second.Replace("`r`n", "`n").Split("`n") | Where-Object { $_ -cne '' })
|
|
75
|
-
return ($firstLines -join "`n") -ceq ($secondLines -join "`n")
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function SelectedPaths($Folder, $Root) {
|
|
79
|
-
$expander = [System.Windows.Automation.ExpandCollapsePattern]$Folder.GetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern)
|
|
80
|
-
try {
|
|
81
|
-
if ($expander.Current.ExpandCollapseState -eq [System.Windows.Automation.ExpandCollapseState]::Collapsed) { $expander.Expand() }
|
|
82
|
-
Start-Sleep -Milliseconds 150
|
|
83
|
-
$menus = @(Descendants $Root | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Menu -and $_.Current.Name -eq $Folder.Current.Name -and -not $_.Current.IsOffscreen })
|
|
84
|
-
if ($menus.Count -ne 1) { throw 'Folder menu not verified' }
|
|
85
|
-
foreach ($radio in @(Descendants $menus[0] | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::RadioButton })) {
|
|
86
|
-
$selection = [System.Windows.Automation.SelectionItemPattern]$radio.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)
|
|
87
|
-
if ($selection.Current.IsSelected) { $radio.Current.HelpText }
|
|
88
|
-
}
|
|
89
|
-
} finally {
|
|
90
|
-
if ($expander.Current.ExpandCollapseState -eq [System.Windows.Automation.ExpandCollapseState]::Expanded) { $expander.Collapse() }
|
|
91
|
-
Start-Sleep -Milliseconds 250
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
do {
|
|
97
|
-
$processes = @(Get-Process -Name Claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and (IsInstalledDesktop $_) })
|
|
98
|
-
if ($processes.Count -ne 1) { Finish 'blocked' 'desktop_window_ambiguous_or_missing' }
|
|
99
|
-
$process = $processes[0]
|
|
100
|
-
$windowId = $process.MainWindowHandle.ToInt64()
|
|
101
|
-
if ($ExpectedWindowId -ne 0 -and $ExpectedWindowId -ne $windowId) { Finish 'blocked' 'desktop_window_changed' }
|
|
102
|
-
$root = [System.Windows.Automation.AutomationElement]::FromHandle($process.MainWindowHandle)
|
|
103
|
-
$elements = Descendants $root
|
|
104
|
-
$dialogs = @(Dialogs $root)
|
|
105
|
-
if ($Action -eq 'Trust') {
|
|
106
|
-
if ($dialogs.Count -gt 0) {
|
|
107
|
-
if ($dialogs.Count -ne 1 -or $dialogs[0].Current.Name -cne 'Trust this workspace?') { Finish 'blocked' 'workspace_trust_dialog_not_verified' }
|
|
108
|
-
$trustElements = Descendants $dialogs[0]
|
|
109
|
-
$trustPaths = @($trustElements | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Text -and (PathEqual $_.Current.Name $request.cwd) })
|
|
110
|
-
$trustButtons = @($trustElements | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and $_.Current.Name -ceq 'Trust workspace' -and $_.Current.IsEnabled -and -not $_.Current.IsOffscreen })
|
|
111
|
-
if ($trustPaths.Count -ne 1 -or $trustButtons.Count -ne 1) { Finish 'blocked' 'workspace_trust_path_mismatch_or_ambiguous' }
|
|
112
|
-
$trustInvoke = $null
|
|
113
|
-
if (-not $trustButtons[0].TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$trustInvoke)) { Finish 'blocked' 'workspace_trust_button_unavailable' }
|
|
114
|
-
$invocationStarted = $true
|
|
115
|
-
([System.Windows.Automation.InvokePattern]$trustInvoke).Invoke()
|
|
116
|
-
do {
|
|
117
|
-
Start-Sleep -Milliseconds 150
|
|
118
|
-
$remainingTrust = @(Descendants $root | Where-Object { $_.Current.Name -ceq 'Trust this workspace?' -and $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Window })
|
|
119
|
-
if ($remainingTrust.Count -eq 0) { Finish 'trusted' 'exact_native_workspace_trust_confirmed' }
|
|
120
|
-
} while ([DateTime]::UtcNow -lt $deadline)
|
|
121
|
-
Finish 'uncertain' 'native_workspace_trust_outcome_unknown'
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if ($dialogs.Count -gt 0) { Finish 'blocked' 'desktop_dialog_requires_attention' }
|
|
125
|
-
$composers = @($elements | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Edit -and $_.Current.Name -eq 'Prompt' -and -not $_.Current.IsOffscreen })
|
|
126
|
-
if ($composers.Count -gt 1) { Finish 'blocked' 'composer_ambiguous_or_missing' }
|
|
127
|
-
if ($composers.Count -eq 1 -and (PromptEqual (TextValue $composers[0]) $request.prompt)) { break }
|
|
128
|
-
if ([DateTime]::UtcNow -ge $deadline) { Finish 'blocked' 'composer_prompt_mismatch' }
|
|
129
|
-
Start-Sleep -Milliseconds 150
|
|
130
|
-
} while ($true)
|
|
131
|
-
|
|
132
|
-
$codeControls = @($elements | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::RadioButton -and $_.Current.Name -eq 'Code' -and -not $_.Current.IsOffscreen })
|
|
133
|
-
if ($codeControls.Count -ne 1) { Finish 'blocked' 'code_mode_not_selected' }
|
|
134
|
-
$codeSelected = ([System.Windows.Automation.SelectionItemPattern]$codeControls[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)).Current.IsSelected
|
|
135
|
-
if (-not $codeSelected) { Finish 'blocked' 'code_mode_not_selected' }
|
|
136
|
-
$pane = $composers[0]
|
|
137
|
-
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
|
138
|
-
for ($level = 0; $level -lt 12; $level++) {
|
|
139
|
-
$pane = $walker.GetParent($pane)
|
|
140
|
-
if ($null -eq $pane) { Finish 'blocked' 'composer_scope_unavailable' }
|
|
141
|
-
if ($pane.Current.ClassName -match '(?:^|\s)dframe-pane-primary(?:\s|$)') { break }
|
|
142
|
-
}
|
|
143
|
-
if ($pane.Current.ClassName -notmatch '(?:^|\s)dframe-pane-primary(?:\s|$)') { Finish 'blocked' 'composer_scope_unavailable' }
|
|
144
|
-
$scope = Descendants $pane
|
|
145
|
-
$pillButtons = @($scope | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and $_.Current.ClassName -like '*pill-h*' -and -not $_.Current.IsOffscreen })
|
|
146
|
-
$localMode = @($pillButtons | Where-Object { $_.Current.Name -eq 'Local' }).Count -eq 1
|
|
147
|
-
$folders = @($pillButtons | Where-Object { $_.Current.Name -notin @('Local', 'Add another folder') })
|
|
148
|
-
if (-not $localMode -or $folders.Count -ne 1) { Finish 'blocked' 'project_folder_ambiguous_or_remote' }
|
|
149
|
-
$worktrees = @($scope | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::CheckBox -and $_.Current.Name -eq 'worktree' })
|
|
150
|
-
$worktreeEnabled = $false
|
|
151
|
-
if ($worktrees.Count -gt 1) { Finish 'blocked' 'original_project_folder_not_verified' }
|
|
152
|
-
if ($worktrees.Count -eq 1) { $worktreeEnabled = ([System.Windows.Automation.TogglePattern]$worktrees[0].GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern)).Current.ToggleState -ne [System.Windows.Automation.ToggleState]::Off }
|
|
153
|
-
if ($worktreeEnabled) { Finish 'blocked' 'original_project_folder_not_verified' }
|
|
154
|
-
|
|
155
|
-
$folder = $folders[0]
|
|
156
|
-
$paths = @(SelectedPaths $folder $root)
|
|
157
|
-
if ($paths.Count -ne 1 -or -not (PathEqual $paths[0] $request.cwd)) { Finish 'blocked' 'selected_project_path_mismatch' }
|
|
158
|
-
$scope = Descendants $pane
|
|
159
|
-
$sends = @($scope | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and $_.Current.Name -eq 'Send' -and -not $_.Current.IsOffscreen })
|
|
160
|
-
if ($sends.Count -ne 1 -or -not $sends[0].Current.IsEnabled) { Finish 'blocked' 'send_button_ambiguous_disabled_or_unavailable' }
|
|
161
|
-
$invoke = $null
|
|
162
|
-
if (-not $sends[0].TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$invoke)) { Finish 'blocked' 'send_button_ambiguous_disabled_or_unavailable' }
|
|
163
|
-
$currentPaths = @(SelectedPaths $folder $root)
|
|
164
|
-
if ($currentPaths.Count -ne 1 -or -not (PathEqual $currentPaths[0] $request.cwd) -or -not (PromptEqual (TextValue $composers[0]) $request.prompt)) { Finish 'blocked' 'composer_changed_before_submission' }
|
|
165
|
-
$currentDialogs = @(Dialogs $root)
|
|
166
|
-
if ($currentDialogs.Count -gt 0) { Finish 'blocked' 'desktop_dialog_requires_attention' }
|
|
167
|
-
if (-not ([System.Windows.Automation.SelectionItemPattern]$codeControls[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern)).Current.IsSelected) { Finish 'blocked' 'code_mode_not_selected' }
|
|
168
|
-
if ($worktrees.Count -eq 1 -and ([System.Windows.Automation.TogglePattern]$worktrees[0].GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern)).Current.ToggleState -ne [System.Windows.Automation.ToggleState]::Off) { Finish 'blocked' 'original_project_folder_not_verified' }
|
|
169
|
-
$snapshot = @{
|
|
170
|
-
windows = @(@{ id = $windowId; installedDesktop = $true })
|
|
171
|
-
dialogs = @()
|
|
172
|
-
codeSelected = $true
|
|
173
|
-
composers = @(@{ text = (TextValue $composers[0]) })
|
|
174
|
-
folderCount = $folders.Count
|
|
175
|
-
localMode = $localMode
|
|
176
|
-
worktreeEnabled = $worktreeEnabled
|
|
177
|
-
selectedFolderPaths = @($paths)
|
|
178
|
-
sendButtons = @(@{ enabled = $true; invokable = $true })
|
|
179
|
-
}
|
|
180
|
-
if ($Action -eq 'Inspect') { Finish 'ready' 'exact_composer_and_project_verified' $snapshot }
|
|
181
|
-
if ($Action -eq 'Trust') { Finish 'absent' 'exact_composer_and_project_verified' $snapshot }
|
|
182
|
-
if ($Action -eq 'SelectFolder') {
|
|
183
|
-
$expander = [System.Windows.Automation.ExpandCollapsePattern]$folder.GetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern)
|
|
184
|
-
$selectionInvoked = $false
|
|
185
|
-
try {
|
|
186
|
-
$expander.Expand()
|
|
187
|
-
Start-Sleep -Milliseconds 150
|
|
188
|
-
$menus = @(Descendants $root | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Menu -and $_.Current.Name -eq $folder.Current.Name -and -not $_.Current.IsOffscreen })
|
|
189
|
-
if ($menus.Count -ne 1) { throw 'Folder menu not verified' }
|
|
190
|
-
$choices = @(Descendants $menus[0] | Where-Object { $_.Current.ControlType -eq [System.Windows.Automation.ControlType]::RadioButton -and (PathEqual $_.Current.HelpText $request.cwd) -and $_.Current.IsEnabled -and -not $_.Current.IsOffscreen })
|
|
191
|
-
if ($choices.Count -ne 1) { throw 'Folder choice not verified' }
|
|
192
|
-
$choiceInvoke = $null
|
|
193
|
-
if (-not $choices[0].TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$choiceInvoke)) { throw 'Folder choice unavailable' }
|
|
194
|
-
if (-not (PromptEqual (TextValue $composers[0]) $request.prompt)) { throw 'Composer changed' }
|
|
195
|
-
([System.Windows.Automation.InvokePattern]$choiceInvoke).Invoke()
|
|
196
|
-
$selectionInvoked = $true
|
|
197
|
-
} finally {
|
|
198
|
-
try {
|
|
199
|
-
if ($expander.Current.ExpandCollapseState -eq [System.Windows.Automation.ExpandCollapseState]::Expanded) { $expander.Collapse() }
|
|
200
|
-
Start-Sleep -Milliseconds 250
|
|
201
|
-
} catch { if (-not $selectionInvoked) { throw } }
|
|
202
|
-
}
|
|
203
|
-
Finish 'selected' 'exact_native_folder_explicitly_selected'
|
|
204
|
-
}
|
|
205
|
-
$invocationStarted = $true
|
|
206
|
-
([System.Windows.Automation.InvokePattern]$invoke).Invoke()
|
|
207
|
-
Finish 'submitted' 'native_send_invoked_once'
|
|
208
|
-
} catch {
|
|
209
|
-
if ($invocationStarted) { Finish 'uncertain' 'native_submit_outcome_unknown' }
|
|
210
|
-
Finish 'blocked' 'native_accessibility_unavailable'
|
|
211
|
-
}
|