@minhspark/codex-mcp-bridge 1.22.21 → 1.24.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 +34 -0
- package/README.md +33 -1
- package/package.json +8 -3
- package/plugin.json +1 -1
- package/scripts/install-antigravity-bridge.mjs +233 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/src/antigravity-bridge.mjs +170 -0
- package/src/antigravity-cli.mjs +248 -0
- package/src/antigravity-deliveries.mjs +93 -0
- package/src/antigravity-desktop.mjs +146 -0
- package/src/antigravity-ipc.mjs +141 -0
- package/src/antigravity-outbound-bridge.mjs +115 -0
- package/src/antigravity-outbound-targets.mjs +244 -0
- package/src/antigravity-sender-context.mjs +162 -0
- package/src/antigravity-sidecar.mjs +100 -0
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +1 -1
- package/src/native-creation-receipts.mjs +4 -2
- package/src/native-relay-companion.mjs +19 -4
- package/src/remote-codex-receiver.mjs +17 -0
- package/src/remote-codex-tools.mjs +62 -0
- package/src/remote-codex.mjs +281 -0
- package/src/windows-pipe-acl.mjs +3 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { spawn as spawnProcess } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { StringDecoder } from "node:string_decoder";
|
|
6
|
+
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
|
+
|
|
9
|
+
function failure(message, code, deliveryState = "not-sent") {
|
|
10
|
+
return Object.assign(new Error(message), { code, deliveryState });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function directory(value) {
|
|
14
|
+
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error("Antigravity CLI cwd must be an absolute directory");
|
|
15
|
+
const resolved = fs.realpathSync.native(value);
|
|
16
|
+
if (!fs.statSync(resolved).isDirectory()) throw new Error("Antigravity CLI cwd must be a directory");
|
|
17
|
+
return resolved;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function positive(value, fallback, name) {
|
|
21
|
+
const result = value ?? fallback;
|
|
22
|
+
if (!Number.isSafeInteger(result) || result < 1) throw new Error(`${name} must be a positive integer`);
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveAntigravityCliBinary({ binary, env = process.env, platform = process.platform } = {}) {
|
|
27
|
+
const selected = binary ?? env.ANTIGRAVITY_CLI_BIN;
|
|
28
|
+
const windows = platform === "win32";
|
|
29
|
+
const name = windows ? "agy.exe" : "agy";
|
|
30
|
+
const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
|
|
31
|
+
const candidates = selected !== undefined ? [selected] : [
|
|
32
|
+
...(windows ? [path.join(env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "agy", "bin", name)] : [path.join(home, ".local", "bin", name)]),
|
|
33
|
+
...(env.PATH ?? env.Path ?? "").split(path.delimiter).filter((item) => path.isAbsolute(item)).map((item) => path.join(item, name)),
|
|
34
|
+
];
|
|
35
|
+
if (selected !== undefined && (typeof selected !== "string" || !path.isAbsolute(selected))) throw new Error("Antigravity CLI binary must be an absolute executable path");
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
try {
|
|
38
|
+
const resolved = fs.realpathSync.native(candidate);
|
|
39
|
+
if (!fs.statSync(resolved).isFile() || windows && !/\.exe$/i.test(resolved)) continue;
|
|
40
|
+
fs.accessSync(resolved, windows ? fs.constants.R_OK : fs.constants.X_OK);
|
|
41
|
+
return resolved;
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
throw new Error("Antigravity CLI executable was not found; set ANTIGRAVITY_CLI_BIN to its absolute path");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class AntigravityCliSession {
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
this.cwd = directory(options.cwd);
|
|
50
|
+
this.env = { ...(options.env ?? process.env) };
|
|
51
|
+
this.binary = resolveAntigravityCliBinary({ binary: options.binary, env: this.env });
|
|
52
|
+
this.spawn = options.spawn ?? spawnProcess;
|
|
53
|
+
this.requestedConversationId = options.conversationId ?? null;
|
|
54
|
+
if (this.requestedConversationId !== null && !UUID.test(this.requestedConversationId)) throw new Error("Invalid Antigravity conversation ID");
|
|
55
|
+
this.startupTimeoutMs = positive(options.startupTimeoutMs, 30000, "startupTimeoutMs");
|
|
56
|
+
this.turnTimeoutMs = positive(options.turnTimeoutMs, 600000, "turnTimeoutMs");
|
|
57
|
+
this.closeTimeoutMs = positive(options.closeTimeoutMs, 3000, "closeTimeoutMs");
|
|
58
|
+
this.maxFrameBytes = positive(options.maxFrameBytes, 2 * 1024 * 1024, "maxFrameBytes");
|
|
59
|
+
this.maxTurnBytes = positive(options.maxTurnBytes, 16 * 1024 * 1024, "maxTurnBytes");
|
|
60
|
+
this.status = "new";
|
|
61
|
+
this.conversationId = null;
|
|
62
|
+
this.permissionMode = null;
|
|
63
|
+
this.child = null;
|
|
64
|
+
this.pending = null;
|
|
65
|
+
this.awaitingResult = false;
|
|
66
|
+
this.lastResult = null;
|
|
67
|
+
this.lastError = null;
|
|
68
|
+
this.deliveryState = "not-sent";
|
|
69
|
+
this.turnBytes = 0;
|
|
70
|
+
this.buffer = "";
|
|
71
|
+
this.decoder = new StringDecoder("utf8");
|
|
72
|
+
this.exited = false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
read() {
|
|
76
|
+
return {
|
|
77
|
+
conversationId: this.conversationId,
|
|
78
|
+
cwd: this.cwd,
|
|
79
|
+
permissionMode: this.permissionMode,
|
|
80
|
+
pid: this.child?.pid ?? null,
|
|
81
|
+
status: this.status,
|
|
82
|
+
deliveryState: this.deliveryState,
|
|
83
|
+
result: this.lastResult ? structuredClone(this.lastResult) : null,
|
|
84
|
+
error: this.lastError ? { code: this.lastError.code, message: this.lastError.message } : null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
start() {
|
|
89
|
+
if (this.startPromise) return this.startPromise;
|
|
90
|
+
if (this.status !== "new") return Promise.reject(failure("Antigravity CLI session cannot be started", "SESSION_CLOSED"));
|
|
91
|
+
this.status = "starting";
|
|
92
|
+
this.startPromise = new Promise((resolve, reject) => {
|
|
93
|
+
this.startResolve = resolve;
|
|
94
|
+
this.startReject = reject;
|
|
95
|
+
});
|
|
96
|
+
this.startTimer = setTimeout(() => this.fail(failure("Antigravity CLI did not emit a valid init event before the deadline", "STARTUP_TIMEOUT")), this.startupTimeoutMs);
|
|
97
|
+
const args = ["--input-format", "stream-json", "--output-format", "stream-json"];
|
|
98
|
+
if (this.requestedConversationId) args.push("--conversation", this.requestedConversationId);
|
|
99
|
+
try {
|
|
100
|
+
this.child = this.spawn(this.binary, args, { cwd: this.cwd, env: this.env, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
101
|
+
this.child.on("error", () => this.fail(failure("Antigravity CLI process failed", "PROCESS_ERROR", this.pending ? "unknown" : this.deliveryState)));
|
|
102
|
+
this.child.stdin.on("error", () => this.fail(failure("Antigravity CLI input pipe failed", "STDIN_ERROR", this.pending ? "unknown" : this.deliveryState)));
|
|
103
|
+
this.child.stdout.on("error", () => this.fail(failure("Antigravity CLI output pipe failed", "STDOUT_ERROR", this.pending ? "unknown" : this.deliveryState)));
|
|
104
|
+
this.child.stderr.on("error", () => this.fail(failure("Antigravity CLI diagnostic pipe failed", "STDERR_ERROR", this.pending ? "unknown" : this.deliveryState)));
|
|
105
|
+
this.child.stderr.resume();
|
|
106
|
+
this.child.stdout.on("data", (chunk) => this.consume(chunk));
|
|
107
|
+
this.child.on("close", (code, signal) => {
|
|
108
|
+
this.exited = true;
|
|
109
|
+
this.exitCode = code;
|
|
110
|
+
this.exitSignal = signal;
|
|
111
|
+
if (this.status === "starting" || this.pending) this.fail(failure("Antigravity CLI exited before completing the operation", "PROCESS_EXIT", this.pending ? "unknown" : this.deliveryState));
|
|
112
|
+
else if (!["failed", "unknown"].includes(this.status)) this.status = "closed";
|
|
113
|
+
this.closeResolve?.();
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
this.fail(failure("Antigravity CLI could not be launched", "SPAWN_FAILED"));
|
|
117
|
+
}
|
|
118
|
+
return this.startPromise;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
consume(chunk) {
|
|
122
|
+
if (["failed", "closed"].includes(this.status)) return;
|
|
123
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
124
|
+
this.turnBytes += bytes.length;
|
|
125
|
+
if (this.turnBytes > this.maxTurnBytes) return this.fail(failure("Antigravity CLI output exceeds its per-turn limit", "OUTPUT_LIMIT", this.pending ? "unknown" : this.deliveryState));
|
|
126
|
+
this.buffer += this.decoder.write(bytes);
|
|
127
|
+
let newline;
|
|
128
|
+
while ((newline = this.buffer.indexOf("\n")) >= 0) {
|
|
129
|
+
const line = this.buffer.slice(0, newline).replace(/\r$/, "");
|
|
130
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
131
|
+
if (Buffer.byteLength(line) > this.maxFrameBytes) return this.fail(failure("Antigravity CLI frame exceeds its limit", "FRAME_LIMIT", this.pending ? "unknown" : this.deliveryState));
|
|
132
|
+
if (!line.trim()) continue;
|
|
133
|
+
try { this.event(JSON.parse(line)); }
|
|
134
|
+
catch (error) { return this.fail(failure(error.message === "Invalid Antigravity event" ? error.message : "Antigravity CLI emitted invalid protocol data", "INVALID_PROTOCOL", this.pending ? "unknown" : this.deliveryState)); }
|
|
135
|
+
if (["failed", "closed"].includes(this.status)) return;
|
|
136
|
+
}
|
|
137
|
+
if (Buffer.byteLength(this.buffer) > this.maxFrameBytes) this.fail(failure("Antigravity CLI frame exceeds its limit", "FRAME_LIMIT", this.pending ? "unknown" : this.deliveryState));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
event(event) {
|
|
141
|
+
if (!event || typeof event !== "object" || Array.isArray(event) || typeof event.event !== "string") throw new Error("Invalid Antigravity event");
|
|
142
|
+
if (event.event === "init") {
|
|
143
|
+
if (this.status !== "starting" || !UUID.test(event.conversation_id ?? "")
|
|
144
|
+
|| this.requestedConversationId && event.conversation_id.toLowerCase() !== this.requestedConversationId.toLowerCase()
|
|
145
|
+
|| directory(event.init?.cwd) !== this.cwd
|
|
146
|
+
|| typeof event.init?.permission_mode !== "string" || !event.init.permission_mode || event.init.permission_mode.length > 128) throw new Error("Invalid Antigravity event");
|
|
147
|
+
this.conversationId = event.conversation_id;
|
|
148
|
+
this.permissionMode = event.init.permission_mode;
|
|
149
|
+
this.status = "idle";
|
|
150
|
+
clearTimeout(this.startTimer);
|
|
151
|
+
this.startResolve(this.read());
|
|
152
|
+
this.startResolve = null;
|
|
153
|
+
this.startReject = null;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!this.conversationId) throw new Error("Invalid Antigravity event");
|
|
157
|
+
if (event.event !== "result") return;
|
|
158
|
+
const result = event.result;
|
|
159
|
+
if (!this.awaitingResult || !result || result.conversation_id !== this.conversationId
|
|
160
|
+
|| typeof result.status !== "string" || !/^[A-Z][A-Z_]{0,127}$/.test(result.status)
|
|
161
|
+
|| typeof result.response !== "string" || result.error !== undefined && typeof result.error !== "string") throw new Error("Invalid Antigravity event");
|
|
162
|
+
this.lastResult = { conversation_id: result.conversation_id, status: result.status, response: result.response, ...(result.error === undefined ? {} : { error: result.error }) };
|
|
163
|
+
this.awaitingResult = false;
|
|
164
|
+
this.deliveryState = "completed";
|
|
165
|
+
if (this.pending) {
|
|
166
|
+
const pending = this.pending;
|
|
167
|
+
this.pending = null;
|
|
168
|
+
clearTimeout(pending.timer);
|
|
169
|
+
this.status = "idle";
|
|
170
|
+
pending.resolve(structuredClone(this.lastResult));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async send(prompt) {
|
|
175
|
+
if (typeof prompt !== "string" || !prompt.trim()) throw failure("Antigravity prompt must be non-empty text", "INVALID_PROMPT");
|
|
176
|
+
const frame = `${JSON.stringify({ event: "user", message: { content: prompt } })}\n`;
|
|
177
|
+
if (Buffer.byteLength(frame) > this.maxFrameBytes) throw failure("Antigravity prompt exceeds its frame limit", "PROMPT_LIMIT");
|
|
178
|
+
await this.start();
|
|
179
|
+
if (this.status !== "idle" || this.pending) throw failure("Antigravity CLI session is busy, closed, or has an uncertain delivery; inspect its receipt instead of resending", "SESSION_UNAVAILABLE", this.deliveryState);
|
|
180
|
+
if (directory(this.cwd) !== this.cwd) throw failure("Antigravity CLI working directory changed", "CWD_CHANGED");
|
|
181
|
+
this.status = "running";
|
|
182
|
+
this.awaitingResult = true;
|
|
183
|
+
this.deliveryState = "unknown";
|
|
184
|
+
this.turnBytes = 0;
|
|
185
|
+
this.lastResult = null;
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
const pending = this.pending;
|
|
189
|
+
if (!pending) return;
|
|
190
|
+
this.pending = null;
|
|
191
|
+
this.status = "unknown";
|
|
192
|
+
this.lastError = failure("Antigravity CLI turn exceeded the observation deadline; it may still be running. Do not resend.", "TURN_TIMEOUT", "unknown");
|
|
193
|
+
pending.reject(this.lastError);
|
|
194
|
+
}, this.turnTimeoutMs);
|
|
195
|
+
this.pending = { resolve, reject, timer };
|
|
196
|
+
try {
|
|
197
|
+
this.child.stdin.write(frame, (error) => {
|
|
198
|
+
if (error) this.fail(failure("Antigravity CLI input write failed; delivery may have started", "STDIN_ERROR", "unknown"));
|
|
199
|
+
});
|
|
200
|
+
} catch {
|
|
201
|
+
this.fail(failure("Antigravity CLI input write failed; delivery may have started", "STDIN_ERROR", "unknown"));
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fail(error) {
|
|
207
|
+
this.lastError = error;
|
|
208
|
+
this.deliveryState = error.deliveryState;
|
|
209
|
+
this.status = error.deliveryState === "unknown" ? "unknown" : "failed";
|
|
210
|
+
clearTimeout(this.startTimer);
|
|
211
|
+
this.startReject?.(error);
|
|
212
|
+
this.startResolve = null;
|
|
213
|
+
this.startReject = null;
|
|
214
|
+
if (this.pending) {
|
|
215
|
+
const pending = this.pending;
|
|
216
|
+
this.pending = null;
|
|
217
|
+
clearTimeout(pending.timer);
|
|
218
|
+
pending.reject(error);
|
|
219
|
+
}
|
|
220
|
+
try { this.child?.kill(); } catch {}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
close() {
|
|
224
|
+
if (this.closePromise) return this.closePromise;
|
|
225
|
+
this.closePromise = this.finishClose();
|
|
226
|
+
return this.closePromise;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async finishClose() {
|
|
230
|
+
if (!this.child || this.exited) {
|
|
231
|
+
this.status = "closed";
|
|
232
|
+
return this.read();
|
|
233
|
+
}
|
|
234
|
+
const closed = new Promise((resolve) => { this.closeResolve = resolve; });
|
|
235
|
+
try { this.child.stdin.end(); } catch {}
|
|
236
|
+
let timer;
|
|
237
|
+
await Promise.race([closed, new Promise((resolve) => { timer = setTimeout(resolve, this.closeTimeoutMs); })]);
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
if (!this.exited) {
|
|
240
|
+
if (this.pending || this.status === "starting") this.fail(failure("Antigravity CLI session was closed before its operation completed", "SESSION_CLOSED", this.pending ? "unknown" : this.deliveryState));
|
|
241
|
+
else try { this.child.kill(); } catch {}
|
|
242
|
+
await Promise.race([closed, new Promise((resolve) => { timer = setTimeout(resolve, this.closeTimeoutMs); })]);
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
}
|
|
245
|
+
this.status = this.exited ? "closed" : "closing";
|
|
246
|
+
return this.read();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ensureBridgeDirectory } from "./antigravity-ipc.mjs";
|
|
5
|
+
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
|
|
8
|
+
export class AntigravityDeliveries {
|
|
9
|
+
constructor(directory) {
|
|
10
|
+
this.directory = ensureBridgeDirectory(directory);
|
|
11
|
+
this.active = new Set();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
file(requestId) {
|
|
15
|
+
if (!UUID.test(requestId)) throw new Error("requestId must be a UUID");
|
|
16
|
+
return path.join(this.directory, `${requestId}.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
read(requestId) {
|
|
20
|
+
const file = this.file(requestId);
|
|
21
|
+
let descriptor;
|
|
22
|
+
try { descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); }
|
|
23
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
24
|
+
let record;
|
|
25
|
+
try {
|
|
26
|
+
const stat = fs.lstatSync(file, { bigint: true });
|
|
27
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n || stat.size > 4n * 1024n * 1024n) throw new Error("Invalid delivery record");
|
|
28
|
+
const opened = fs.fstatSync(descriptor, { bigint: true });
|
|
29
|
+
if (!opened.isFile() || opened.nlink !== 1n || opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size || opened.mtimeNs !== stat.mtimeNs) throw new Error("Delivery record changed before reading");
|
|
30
|
+
const buffer = Buffer.alloc(Number(opened.size) + 1);
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
while (bytes < buffer.length) {
|
|
33
|
+
const count = fs.readSync(descriptor, buffer, bytes, buffer.length - bytes, bytes);
|
|
34
|
+
if (!count) break;
|
|
35
|
+
bytes += count;
|
|
36
|
+
}
|
|
37
|
+
const after = fs.fstatSync(descriptor, { bigint: true });
|
|
38
|
+
if (bytes !== Number(opened.size) || after.size !== opened.size || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) throw new Error("Delivery record changed while reading");
|
|
39
|
+
record = JSON.parse(buffer.subarray(0, bytes).toString("utf8"));
|
|
40
|
+
} finally { fs.closeSync(descriptor); }
|
|
41
|
+
if (record.requestId !== requestId) throw new Error("Delivery identity mismatch");
|
|
42
|
+
if (record.status === "pending" && !this.active.has(requestId)) return { ...record, status: "unknown", reason: "This process cannot observe the original worker. Inspect the exact destination; do not resend." };
|
|
43
|
+
return record;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
identify(requestId, conversationId) {
|
|
47
|
+
if (!UUID.test(conversationId) || !this.active.has(requestId)) throw new Error("Cannot bind inactive delivery");
|
|
48
|
+
const record = this.read(requestId);
|
|
49
|
+
record.conversationId = conversationId;
|
|
50
|
+
this.write(record);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
write(record) {
|
|
54
|
+
const temporary = `${this.file(record.requestId)}.${process.pid}.tmp`;
|
|
55
|
+
fs.writeFileSync(temporary, JSON.stringify(record), { flag: "wx", mode: 0o600 });
|
|
56
|
+
fs.renameSync(temporary, this.file(record.requestId));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async dispatch(request, operation) {
|
|
60
|
+
const { requestId, ...identity } = request;
|
|
61
|
+
const digest = crypto.createHash("sha256").update(JSON.stringify(identity)).digest("hex");
|
|
62
|
+
const existing = this.read(requestId);
|
|
63
|
+
if (existing) {
|
|
64
|
+
if (existing.digest !== digest) throw new Error("requestId was already used for a different delivery");
|
|
65
|
+
return existing;
|
|
66
|
+
}
|
|
67
|
+
const record = { requestId, digest, status: "pending", cwd: request.cwd, sender: request.sender, conversationId: request.conversationId ?? null, surface: request.surface, createdAt: new Date().toISOString() };
|
|
68
|
+
try { fs.writeFileSync(this.file(requestId), JSON.stringify(record), { flag: "wx", mode: 0o600 }); }
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code !== "EEXIST") throw error;
|
|
71
|
+
throw new Error("Delivery is owned by another process. Inspect requestId; do not resend.");
|
|
72
|
+
}
|
|
73
|
+
this.active.add(requestId);
|
|
74
|
+
void Promise.resolve().then(operation).then((result) => {
|
|
75
|
+
record.status = ["accepted", "sent_unconfirmed", "held", "refused"].includes(result?.status) ? result.status : result?.status && result.status !== "SUCCESS" ? "failed" : "completed";
|
|
76
|
+
record.result = result;
|
|
77
|
+
record.conversationId = result?.conversationId ?? result?.conversation_id ?? record.conversationId;
|
|
78
|
+
}, (error) => {
|
|
79
|
+
record.status = ["not_sent", "not-sent"].includes(error.deliveryState) ? "failed" : "unknown";
|
|
80
|
+
record.reason = error.message;
|
|
81
|
+
if (typeof error.msgId === "string" && UUID.test(error.msgId)) record.result = { msgId: error.msgId, completed: false };
|
|
82
|
+
if (error.accountContext) record.result = { ...record.result, accountContext: error.accountContext };
|
|
83
|
+
}).finally(() => {
|
|
84
|
+
record.finishedAt = new Date().toISOString();
|
|
85
|
+
try {
|
|
86
|
+
record.conversationId ??= this.read(requestId)?.conversationId;
|
|
87
|
+
this.write(record);
|
|
88
|
+
} catch (error) { process.stderr.write(`Could not persist Antigravity delivery outcome: ${error.code ?? "WRITE_FAILED"}\n`); }
|
|
89
|
+
this.active.delete(requestId);
|
|
90
|
+
});
|
|
91
|
+
return record;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
const SURFACES = { desktop: "antigravity", cli: "antigravity-cli" };
|
|
10
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
11
|
+
|
|
12
|
+
function failure(code, message) {
|
|
13
|
+
return Object.assign(new Error(message), { code, deliveryState: "not_sent" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function nativeDiagnostic(error, env, args) {
|
|
17
|
+
const exitCode = Number.isInteger(error.code) ? String(error.code) : /^[A-Z_]{2,40}$/.test(error.code ?? "") ? error.code : "unknown";
|
|
18
|
+
let detail = "";
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(error.stdout);
|
|
21
|
+
if (typeof parsed.error === "string") detail = parsed.error;
|
|
22
|
+
} catch {}
|
|
23
|
+
for (const value of [...Object.entries(env).filter(([key]) => /ANTIGRAVITY|TOKEN|SECRET|PASSWORD|AUTH|KEY/i.test(key)).map(([, value]) => value), ...args.slice(1)]) {
|
|
24
|
+
if (typeof value === "string" && value) detail = detail.split(value).join("[redacted]");
|
|
25
|
+
}
|
|
26
|
+
detail = detail.replace(/https?:\/\/[^\s"']+/gi, "[endpoint]").replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 400);
|
|
27
|
+
return `exit ${exitCode}${detail ? `: ${detail}` : ""}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function canonicalCwd(value) {
|
|
31
|
+
if (typeof value !== "string" || !path.isAbsolute(value)) throw failure("ANTIGRAVITY_CWD_INVALID", "An absolute existing workspace directory is required");
|
|
32
|
+
const resolved = fs.realpathSync.native(value);
|
|
33
|
+
if (!fs.statSync(resolved).isDirectory()) throw failure("ANTIGRAVITY_CWD_INVALID", "Workspace is not a directory");
|
|
34
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function workspaceCwd(uris) {
|
|
38
|
+
if (typeof uris === "string") uris = JSON.parse(uris);
|
|
39
|
+
if (!Array.isArray(uris) || uris.length !== 1 || typeof uris[0] !== "string") throw failure("ANTIGRAVITY_WORKSPACE_AMBIGUOUS", "Conversation must have exactly one workspace");
|
|
40
|
+
const uri = new URL(uris[0]);
|
|
41
|
+
if (uri.protocol !== "file:" || uri.search || uri.hash) throw failure("ANTIGRAVITY_CWD_INVALID", "Conversation workspace must be a local file URI");
|
|
42
|
+
return canonicalCwd(fileURLToPath(uri));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function listAntigravityConversations({ home = os.homedir(), surface = "all", limit = 100 } = {}) {
|
|
46
|
+
if (surface !== "all" && !Object.hasOwn(SURFACES, surface)) throw failure("ANTIGRAVITY_SURFACE_INVALID", "Surface must be desktop, cli, or all");
|
|
47
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error("Conversation limit must be between 1 and 1000");
|
|
48
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
49
|
+
const rows = [];
|
|
50
|
+
for (const [kind, directory] of Object.entries(SURFACES)) {
|
|
51
|
+
if (surface !== "all" && surface !== kind) continue;
|
|
52
|
+
const filename = path.join(home, ".gemini", directory, "conversation_summaries.db");
|
|
53
|
+
if (!fs.existsSync(filename)) continue;
|
|
54
|
+
const db = new DatabaseSync(filename, { readOnly: true });
|
|
55
|
+
try {
|
|
56
|
+
const records = db.prepare("SELECT conversation_id, title, workspace_uris, project_id, status, not_fully_idle, killed, app_data_dir, source FROM conversation_summaries WHERE app_data_dir = ? ORDER BY last_modified_time DESC LIMIT ?").all(directory, limit);
|
|
57
|
+
for (const row of records) {
|
|
58
|
+
if (!UUID.test(row.conversation_id) || row.killed) continue;
|
|
59
|
+
if (kind === "desktop" && ["CORTEX_TRAJECTORY_SOURCE_CLI", "CORTEX_TRAJECTORY_SOURCE_VSCODE", "CORTEX_TRAJECTORY_SOURCE_VISUAL_STUDIO", "CORTEX_TRAJECTORY_SOURCE_SDK", "CORTEX_TRAJECTORY_SOURCE_PYTHON_SDK"].includes(row.source)) continue;
|
|
60
|
+
let cwd;
|
|
61
|
+
try { cwd = workspaceCwd(row.workspace_uris); } catch { continue; }
|
|
62
|
+
rows.push({ conversationId: row.conversation_id, cwd, projectId: row.project_id, title: row.title, status: row.not_fully_idle ? "busy" : row.status || "unknown", surface: kind });
|
|
63
|
+
}
|
|
64
|
+
} finally { db.close(); }
|
|
65
|
+
}
|
|
66
|
+
return rows;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function nativeMetadata(result) {
|
|
70
|
+
return result.metadata ?? result.response?.conversationMetadata?.metadata ?? result.response?.conversation_metadata?.metadata ?? result.payload?.conversationMetadata?.metadata ?? result.payload?.conversation_metadata?.metadata ?? result.conversationMetadata?.metadata ?? result.conversation_metadata?.metadata;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function metadataShape(result) {
|
|
74
|
+
const rows = [];
|
|
75
|
+
const visit = (value, prefix, depth) => {
|
|
76
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || depth > 3) return;
|
|
77
|
+
for (const key of Object.keys(value).slice(0, 20)) {
|
|
78
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/.test(key)) continue;
|
|
79
|
+
const field = prefix ? `${prefix}.${key}` : key;
|
|
80
|
+
rows.push(field);
|
|
81
|
+
visit(value[key], field, depth + 1);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
visit(result, "", 0);
|
|
85
|
+
return rows.join(", ").slice(0, 800);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sanitizeAntigravityMetadata(result, conversationId) {
|
|
89
|
+
const metadata = nativeMetadata(result);
|
|
90
|
+
if (!metadata || typeof metadata !== "object") throw failure("ANTIGRAVITY_METADATA_UNSUPPORTED", `Native Antigravity metadata is missing or unsupported (fields: ${metadataShape(result)})`);
|
|
91
|
+
const returnedId = result.conversationId ?? result.conversation_id ?? result.response?.conversationId ?? result.response?.conversation_id ?? metadata.conversationId ?? metadata.conversation_id;
|
|
92
|
+
if (returnedId !== undefined && returnedId !== conversationId) throw failure("ANTIGRAVITY_METADATA_MISMATCH", "Native metadata identifies a different conversation");
|
|
93
|
+
return {
|
|
94
|
+
conversationId,
|
|
95
|
+
cwd: workspaceCwd(metadata.workspaceUris ?? metadata.workspace_uris),
|
|
96
|
+
projectId: metadata.projectId ?? metadata.project_id ?? "",
|
|
97
|
+
surface: "desktop",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function runAgentApi(options) {
|
|
102
|
+
try { return await executeAgentApi(options); } catch (error) {
|
|
103
|
+
error.deliveryState ??= "not_sent";
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function executeAgentApi({ command, conversationId, prompt, expectedCwd, home = os.homedir(), env = process.env, executable = env.ANTIGRAVITY_AGENTAPI_EXE, run = execute, list = listAntigravityConversations }) {
|
|
109
|
+
if (!UUID.test(conversationId)) throw failure("ANTIGRAVITY_ID_INVALID", "An exact conversation UUID is required");
|
|
110
|
+
if (!["metadata", "send"].includes(command)) throw new Error("Unsupported Antigravity agentapi command");
|
|
111
|
+
if (!env.ANTIGRAVITY_LS_ADDRESS || !env.ANTIGRAVITY_CSRF_TOKEN) throw failure("ANTIGRAVITY_SIDECAR_REQUIRED", "Antigravity must launch the bridge sidecar before native requests are available");
|
|
112
|
+
if (executable && (!path.isAbsolute(executable) || /\.(bat|cmd|ps1)$/i.test(executable))) throw failure("ANTIGRAVITY_EXECUTABLE_INVALID", "Antigravity agentapi executable must be an absolute native executable path");
|
|
113
|
+
if (!executable && process.platform === "win32") throw failure("ANTIGRAVITY_EXECUTABLE_MISSING", "The Antigravity sidecar must provide ANTIGRAVITY_AGENTAPI_EXE on Windows");
|
|
114
|
+
const invoke = async (args) => {
|
|
115
|
+
let output;
|
|
116
|
+
try {
|
|
117
|
+
output = await run(executable || "agentapi", executable ? ["agentapi", ...args] : args, { env, encoding: "utf8", timeout: 30000, maxBuffer: 1048576, windowsHide: true, shell: false });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const nativeError = failure(error.killed || error.code === "ETIMEDOUT" ? "ANTIGRAVITY_OUTCOME_UNKNOWN" : "ANTIGRAVITY_NATIVE_FAILED", `Native Antigravity request failed (${nativeDiagnostic(error, env, args)}); inspect the original conversation before resending`);
|
|
120
|
+
nativeError.deliveryState = args[0] === "send-message" ? "unknown" : "not_sent";
|
|
121
|
+
throw nativeError;
|
|
122
|
+
}
|
|
123
|
+
let result;
|
|
124
|
+
try { result = JSON.parse(output.stdout); } catch { throw Object.assign(failure("ANTIGRAVITY_RESPONSE_INVALID", "Native Antigravity returned invalid JSON; do not automatically resend"), { deliveryState: args[0] === "send-message" ? "unknown" : "not_sent" }); }
|
|
125
|
+
if (!result || typeof result !== "object" || Array.isArray(result) || result.error || result.success === false) throw Object.assign(failure("ANTIGRAVITY_NATIVE_REJECTED", "Native Antigravity rejected the request"), { deliveryState: args[0] === "send-message" ? "unknown" : "not_sent" });
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
128
|
+
if (command === "metadata") return invoke(["get-conversation-metadata", conversationId]);
|
|
129
|
+
if (typeof prompt !== "string" || !prompt.trim() || prompt.length > 32768 || prompt.includes("\0")) throw new Error("Prompt must contain between 1 and 32768 characters without NUL");
|
|
130
|
+
const cwd = canonicalCwd(expectedCwd);
|
|
131
|
+
const select = async () => {
|
|
132
|
+
const records = (await list({ home, surface: "desktop", limit: 1000 })).filter((row) => row.conversationId === conversationId);
|
|
133
|
+
if (records.length !== 1) throw failure("ANTIGRAVITY_RECIPIENT_MISSING", "Exact desktop conversation is unavailable or has an ambiguous workspace");
|
|
134
|
+
const row = records[0];
|
|
135
|
+
if (row.surface !== "desktop" || canonicalCwd(row.cwd) !== cwd) throw failure("ANTIGRAVITY_WORKSPACE_MISMATCH", "Conversation workspace does not match the authorized workspace");
|
|
136
|
+
if (row.status !== "CASCADE_RUN_STATUS_IDLE") throw failure("ANTIGRAVITY_RECIPIENT_BUSY", "Conversation is busy or its idle state cannot be verified");
|
|
137
|
+
return row;
|
|
138
|
+
};
|
|
139
|
+
const before = await select();
|
|
140
|
+
const metadata = sanitizeAntigravityMetadata(await invoke(["get-conversation-metadata", conversationId]), conversationId);
|
|
141
|
+
if (metadata.cwd !== cwd || metadata.projectId !== before.projectId) throw failure("ANTIGRAVITY_METADATA_MISMATCH", "Native metadata does not match the selected conversation workspace and project");
|
|
142
|
+
const current = await select();
|
|
143
|
+
if (current.projectId !== before.projectId) throw failure("ANTIGRAVITY_METADATA_MISMATCH", "Conversation project changed during delivery preflight");
|
|
144
|
+
await invoke(["send-message", conversationId, prompt]);
|
|
145
|
+
return { conversationId, status: "accepted", completed: false, surface: "desktop" };
|
|
146
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
|
|
7
|
+
import { relayFileIdentity, removeRelayFile } from "./native-relay-files.mjs";
|
|
8
|
+
|
|
9
|
+
const LIMIT = 2 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
async function socketRefused(socketPath) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const socket = net.createConnection(socketPath);
|
|
14
|
+
let settled = false;
|
|
15
|
+
const done = (refused) => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
socket.destroy();
|
|
20
|
+
resolve(refused);
|
|
21
|
+
};
|
|
22
|
+
const timer = setTimeout(() => done(false), 1000);
|
|
23
|
+
socket.on("connect", () => done(false));
|
|
24
|
+
socket.on("error", (error) => done(error.code === "ECONNREFUSED"));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function antigravityBridgeDirectory(home = os.homedir()) {
|
|
29
|
+
return path.join(home, ".codex", "antigravity-bridge");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function antigravitySocket(directory = antigravityBridgeDirectory()) {
|
|
33
|
+
const hash = crypto.createHash("sha256").update(path.resolve(directory).toLowerCase()).digest("hex").slice(0, 24);
|
|
34
|
+
return process.platform === "win32" ? `\\\\.\\pipe\\LOCAL\\codex-bridge-antigravity-${hash}` : path.join(directory, "desktop.sock");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ensureBridgeDirectory(directory) {
|
|
38
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
39
|
+
const stat = fs.lstatSync(directory);
|
|
40
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Bridge directory must be a real directory");
|
|
41
|
+
if (typeof process.getuid === "function" && (stat.uid !== process.getuid() || (stat.mode & 0o077))) throw new Error("Bridge directory must be owned by this user with mode 0700");
|
|
42
|
+
return directory;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function callAntigravityDesktop(request, { directory = antigravityBridgeDirectory(), timeoutMs = 30000 } = {}) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const socket = net.createConnection(antigravitySocket(directory));
|
|
48
|
+
let buffer = Buffer.alloc(0);
|
|
49
|
+
let finished = false;
|
|
50
|
+
let dispatched = false;
|
|
51
|
+
const done = (error, result) => {
|
|
52
|
+
if (finished) return;
|
|
53
|
+
finished = true;
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
socket.destroy();
|
|
56
|
+
if (error) {
|
|
57
|
+
error.deliveryState ??= dispatched ? "unknown" : "not_sent";
|
|
58
|
+
reject(error);
|
|
59
|
+
} else resolve(result);
|
|
60
|
+
};
|
|
61
|
+
const timer = setTimeout(() => done(Object.assign(new Error("Antigravity sidecar did not reply; inspect the original requestId before retrying"), { deliveryState: "unknown" })), timeoutMs);
|
|
62
|
+
socket.on("error", (error) => done(error));
|
|
63
|
+
socket.on("end", () => done(new Error("Antigravity sidecar disconnected before replying")));
|
|
64
|
+
socket.on("connect", () => {
|
|
65
|
+
const frame = JSON.stringify(request) + "\n";
|
|
66
|
+
if (Buffer.byteLength(frame) > LIMIT) return done(new Error("Request exceeds size limit"));
|
|
67
|
+
dispatched = true;
|
|
68
|
+
socket.write(frame);
|
|
69
|
+
});
|
|
70
|
+
socket.on("data", (chunk) => {
|
|
71
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
72
|
+
if (buffer.length > LIMIT) return done(new Error("Sidecar response exceeds size limit"));
|
|
73
|
+
const end = buffer.indexOf(10);
|
|
74
|
+
if (end < 0) return;
|
|
75
|
+
try {
|
|
76
|
+
const response = JSON.parse(buffer.subarray(0, end).toString("utf8"));
|
|
77
|
+
if (response.error) return done(Object.assign(new Error(response.error), { deliveryState: response.deliveryState }));
|
|
78
|
+
done(null, response.result);
|
|
79
|
+
} catch (error) { done(error); }
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function serveAntigravityDesktop(handler, { directory = antigravityBridgeDirectory() } = {}) {
|
|
85
|
+
ensureBridgeDirectory(directory);
|
|
86
|
+
const socketPath = antigravitySocket(directory);
|
|
87
|
+
let ready = false;
|
|
88
|
+
const connections = new Set();
|
|
89
|
+
const server = net.createServer((socket) => {
|
|
90
|
+
if (!ready || connections.size >= 32) return socket.destroy();
|
|
91
|
+
connections.add(socket);
|
|
92
|
+
socket.on("close", () => connections.delete(socket));
|
|
93
|
+
socket.on("error", () => {});
|
|
94
|
+
socket.setTimeout(35000, () => socket.destroy());
|
|
95
|
+
let buffer = Buffer.alloc(0);
|
|
96
|
+
let received = false;
|
|
97
|
+
socket.on("data", async (chunk) => {
|
|
98
|
+
if (received) return;
|
|
99
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
100
|
+
if (buffer.length > LIMIT) return socket.destroy();
|
|
101
|
+
const end = buffer.indexOf(10);
|
|
102
|
+
if (end < 0) return;
|
|
103
|
+
received = true;
|
|
104
|
+
try {
|
|
105
|
+
const result = await handler(JSON.parse(buffer.subarray(0, end).toString("utf8")));
|
|
106
|
+
const frame = JSON.stringify({ result }) + "\n";
|
|
107
|
+
if (Buffer.byteLength(frame) > LIMIT) throw new Error("Sidecar response exceeds size limit");
|
|
108
|
+
socket.end(frame);
|
|
109
|
+
} catch (error) { socket.end(JSON.stringify({ error: error.message, deliveryState: error.deliveryState }) + "\n"); }
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
const privatePath = process.platform === "win32" ? socketPath : path.join(directory, `.${crypto.randomBytes(6).toString("hex")}.sock`);
|
|
113
|
+
let identity;
|
|
114
|
+
await new Promise((resolve, reject) => {
|
|
115
|
+
server.once("error", reject);
|
|
116
|
+
server.listen(privatePath, () => { server.off("error", reject); resolve(); });
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
if (process.platform === "win32") await protectCurrentUserPipe(socketPath);
|
|
120
|
+
else {
|
|
121
|
+
fs.chmodSync(privatePath, 0o600);
|
|
122
|
+
try { fs.linkSync(privatePath, socketPath); }
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error.code !== "EEXIST") throw error;
|
|
125
|
+
const existing = relayFileIdentity(socketPath, "socket");
|
|
126
|
+
if (!await socketRefused(socketPath)) throw new Error("Another Antigravity sidecar owns the socket");
|
|
127
|
+
removeRelayFile(socketPath, { expected: existing });
|
|
128
|
+
fs.linkSync(privatePath, socketPath);
|
|
129
|
+
}
|
|
130
|
+
fs.unlinkSync(privatePath);
|
|
131
|
+
identity = relayFileIdentity(socketPath, "socket");
|
|
132
|
+
}
|
|
133
|
+
ready = true;
|
|
134
|
+
} catch (error) { server.close(); throw error; }
|
|
135
|
+
return { server, close: () => new Promise((resolve) => {
|
|
136
|
+
ready = false;
|
|
137
|
+
for (const socket of connections) socket.destroy();
|
|
138
|
+
if (identity) removeRelayFile(socketPath, { expected: identity });
|
|
139
|
+
server.close(resolve);
|
|
140
|
+
}) };
|
|
141
|
+
}
|