apple-tools-mcp 1.2.0 → 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/README.md +261 -7
- package/contacts.js +71 -19
- package/index.js +133 -87
- package/indexer.js +33 -23
- package/lib/appleScript.js +292 -0
- package/lib/audit.js +22 -14
- package/lib/calendarWrite.js +594 -0
- package/lib/contactsWrite.js +300 -0
- package/lib/indexGate.js +49 -0
- package/lib/lancedbTables.js +307 -0
- package/lib/mailWrite.js +464 -0
- package/lib/messagesWrite.js +281 -0
- package/lib/writeBridge.js +214 -0
- package/lib/writeGuards.js +250 -0
- package/lib/writeRouting.js +69 -0
- package/lib/writeTools.js +396 -0
- package/package.json +4 -2
- package/scripts/smoke-writes.js +313 -0
- package/search.js +7 -18
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages (iMessage / SMS) write operations.
|
|
3
|
+
*
|
|
4
|
+
* Supported identifiers:
|
|
5
|
+
* - `to`: a phone number in E.164 form (+15551234567) or an Apple ID email.
|
|
6
|
+
* One or more; more than one requires confirmation.
|
|
7
|
+
* - `chat_id`: an existing chat GUID from chat.db (for example
|
|
8
|
+
* "iMessage;-;+15551234567" for a 1:1 chat or "iMessage;+;chat123456789"
|
|
9
|
+
* for a group). The GUID is verified against chat.db before sending, so a
|
|
10
|
+
* made-up chat id is refused rather than silently delivered elsewhere.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { safeSqlite3Json } from "./shell.js";
|
|
16
|
+
import { escapeSQL } from "./validators.js";
|
|
17
|
+
import { runAppleScript, asString, TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
|
|
18
|
+
import {
|
|
19
|
+
planWrite,
|
|
20
|
+
normalizeList,
|
|
21
|
+
isMessagesHandle,
|
|
22
|
+
validateChatGuid,
|
|
23
|
+
validateBody,
|
|
24
|
+
writeErrorMessage,
|
|
25
|
+
writeSuccessMessage,
|
|
26
|
+
isFlagTrue,
|
|
27
|
+
truncate,
|
|
28
|
+
MAX_RECIPIENTS
|
|
29
|
+
} from "./writeGuards.js";
|
|
30
|
+
|
|
31
|
+
const CHAT_DB = path.join(process.env.HOME || "", "Library", "Messages", "chat.db");
|
|
32
|
+
|
|
33
|
+
export const SERVICE_IMESSAGE = "imessage";
|
|
34
|
+
export const SERVICE_SMS = "sms";
|
|
35
|
+
export const SERVICE_AUTO = "auto";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Look up a chat GUID in chat.db and count its participants.
|
|
39
|
+
* @returns {{ found: boolean, participantCount: number, displayName: string, error: string|null }}
|
|
40
|
+
*/
|
|
41
|
+
export function lookupChat(guid, { queryFn = safeSqlite3Json, dbPath = CHAT_DB } = {}) {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(dbPath)) {
|
|
44
|
+
return { found: false, participantCount: 0, displayName: "", error: "Messages database not found" };
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
return { found: false, participantCount: 0, displayName: "", error: "Messages database not readable" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const query = `
|
|
51
|
+
SELECT c.guid AS guid,
|
|
52
|
+
COALESCE(c.display_name, '') AS displayName,
|
|
53
|
+
COUNT(chj.handle_id) AS participantCount
|
|
54
|
+
FROM chat c
|
|
55
|
+
LEFT JOIN chat_handle_join chj ON chj.chat_id = c.ROWID
|
|
56
|
+
WHERE c.guid = '${escapeSQL(guid)}'
|
|
57
|
+
GROUP BY c.ROWID
|
|
58
|
+
LIMIT 1
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const rows = queryFn(dbPath, query, { timeout: 15000 });
|
|
63
|
+
if (!rows || rows.length === 0) {
|
|
64
|
+
return { found: false, participantCount: 0, displayName: "", error: null };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
found: true,
|
|
68
|
+
participantCount: Number(rows[0].participantCount) || 1,
|
|
69
|
+
displayName: rows[0].displayName || "",
|
|
70
|
+
error: null
|
|
71
|
+
};
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { found: false, participantCount: 0, displayName: "", error: e.message };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate an attachment path supplied by the caller.
|
|
79
|
+
* The file must already exist on this host; the tool never creates files.
|
|
80
|
+
*/
|
|
81
|
+
export function validateAttachmentPath(value) {
|
|
82
|
+
if (value === undefined || value === null || value === "") return { filePath: null, error: null };
|
|
83
|
+
if (typeof value !== "string") return { filePath: null, error: "attachment_path must be a string" };
|
|
84
|
+
if (!path.isAbsolute(value)) return { filePath: null, error: "attachment_path must be an absolute path on this Mac" };
|
|
85
|
+
if (value.includes("\u0000")) return { filePath: null, error: "attachment_path contains invalid characters" };
|
|
86
|
+
|
|
87
|
+
let resolved;
|
|
88
|
+
try {
|
|
89
|
+
resolved = fs.realpathSync(value);
|
|
90
|
+
const stat = fs.statSync(resolved);
|
|
91
|
+
if (!stat.isFile()) return { filePath: null, error: "attachment_path must point to a file" };
|
|
92
|
+
} catch {
|
|
93
|
+
return { filePath: null, error: "attachment_path does not exist on this Mac" };
|
|
94
|
+
}
|
|
95
|
+
return { filePath: resolved, error: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serviceHandler() {
|
|
99
|
+
return `on atmService(kind)
|
|
100
|
+
tell application "Messages"
|
|
101
|
+
repeat with acc in accounts
|
|
102
|
+
try
|
|
103
|
+
if kind is "sms" then
|
|
104
|
+
if (service type of acc) is SMS then return acc
|
|
105
|
+
else
|
|
106
|
+
if (service type of acc) is iMessage then return acc
|
|
107
|
+
end if
|
|
108
|
+
end try
|
|
109
|
+
end repeat
|
|
110
|
+
end tell
|
|
111
|
+
if kind is "sms" then
|
|
112
|
+
error "SMS_SERVICE_NOT_FOUND"
|
|
113
|
+
else
|
|
114
|
+
error "IMESSAGE_SERVICE_NOT_FOUND"
|
|
115
|
+
end if
|
|
116
|
+
end atmService`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildSendToHandlesScript({ handles, text, service, attachmentPath }) {
|
|
120
|
+
const sends = handles
|
|
121
|
+
.map((handle) => {
|
|
122
|
+
const lines = [` set theTarget to participant ${asString(handle)} of targetService`];
|
|
123
|
+
if (text) lines.push(` send ${asString(text)} to theTarget`);
|
|
124
|
+
if (attachmentPath) lines.push(` send (POSIX file ${asString(attachmentPath)}) to theTarget`);
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
})
|
|
127
|
+
.join("\n");
|
|
128
|
+
|
|
129
|
+
const primary = service === SERVICE_SMS ? "sms" : "imessage";
|
|
130
|
+
const fallback = service === SERVICE_AUTO
|
|
131
|
+
? `if targetService is missing value then
|
|
132
|
+
set targetService to atmService("sms")
|
|
133
|
+
end if`
|
|
134
|
+
: "";
|
|
135
|
+
|
|
136
|
+
return `${serviceHandler()}
|
|
137
|
+
|
|
138
|
+
set targetService to missing value
|
|
139
|
+
try
|
|
140
|
+
set targetService to atmService(${asString(primary)})
|
|
141
|
+
end try
|
|
142
|
+
${fallback}
|
|
143
|
+
if targetService is missing value then error "IMESSAGE_SERVICE_NOT_FOUND"
|
|
144
|
+
tell application "Messages"
|
|
145
|
+
${sends}
|
|
146
|
+
end tell
|
|
147
|
+
return "OK"`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function buildSendToChatScript({ chatGuid, text, attachmentPath }) {
|
|
151
|
+
const lines = [` set theChat to chat id ${asString(chatGuid)}`];
|
|
152
|
+
if (text) lines.push(` send ${asString(text)} to theChat`);
|
|
153
|
+
if (attachmentPath) lines.push(` send (POSIX file ${asString(attachmentPath)}) to theChat`);
|
|
154
|
+
|
|
155
|
+
return `tell application "Messages"
|
|
156
|
+
${lines.join("\n")}
|
|
157
|
+
end tell
|
|
158
|
+
return "OK"`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function failure(action, summary, result, secrets) {
|
|
162
|
+
if (result.kind === "tcc") {
|
|
163
|
+
return `${action} failed — attempted to ${summary}. ${TCC_GUIDANCE}`;
|
|
164
|
+
}
|
|
165
|
+
if (result.kind === "attribution") {
|
|
166
|
+
return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
|
|
167
|
+
}
|
|
168
|
+
if (result.kind === "app_unavailable") {
|
|
169
|
+
return `${action} failed — attempted to ${summary}. Messages.app could not be reached on this host.`;
|
|
170
|
+
}
|
|
171
|
+
const raw = String(result.error || "");
|
|
172
|
+
if (raw.includes("SMS_SERVICE_NOT_FOUND")) {
|
|
173
|
+
return `${action} failed — attempted to ${summary}. No SMS relay service is configured in Messages (Text Message Forwarding).`;
|
|
174
|
+
}
|
|
175
|
+
if (raw.includes("IMESSAGE_SERVICE_NOT_FOUND")) {
|
|
176
|
+
return `${action} failed — attempted to ${summary}. No enabled iMessage account was found in Messages.`;
|
|
177
|
+
}
|
|
178
|
+
return writeErrorMessage(action, summary, new Error(raw || "unknown error"), secrets);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Send an iMessage/SMS to one or more handles, or into an existing chat.
|
|
183
|
+
*/
|
|
184
|
+
export function messagesSend(args = {}, deps = {}) {
|
|
185
|
+
const action = "messages_send";
|
|
186
|
+
|
|
187
|
+
const body = validateBody(args.text, { required: false, field: "text" });
|
|
188
|
+
if (body.error) return { ok: false, message: `${action} refused: ${body.error}` };
|
|
189
|
+
|
|
190
|
+
const attachment = validateAttachmentPath(args.attachment_path);
|
|
191
|
+
if (attachment.error) return { ok: false, message: `${action} refused: ${attachment.error}` };
|
|
192
|
+
|
|
193
|
+
if (!body.text && !attachment.filePath) {
|
|
194
|
+
return { ok: false, message: `${action} refused: provide text, attachment_path, or both.` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const serviceRaw = args.service === undefined ? SERVICE_AUTO : String(args.service).toLowerCase();
|
|
198
|
+
if (![SERVICE_AUTO, SERVICE_IMESSAGE, SERVICE_SMS].includes(serviceRaw)) {
|
|
199
|
+
return { ok: false, message: `${action} refused: service must be "auto", "imessage", or "sms"` };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const dryRun = isFlagTrue(args.dry_run);
|
|
203
|
+
const confirm = isFlagTrue(args.confirm);
|
|
204
|
+
|
|
205
|
+
if (args.chat_id) {
|
|
206
|
+
const guid = validateChatGuid(args.chat_id);
|
|
207
|
+
if (!guid) return { ok: false, message: `${action} refused: chat_id is not a valid chat GUID` };
|
|
208
|
+
|
|
209
|
+
const chat = lookupChat(guid, deps);
|
|
210
|
+
if (chat.error) {
|
|
211
|
+
return { ok: false, message: `${action} refused: could not verify chat_id against the Messages database (${truncate(chat.error, 120)})` };
|
|
212
|
+
}
|
|
213
|
+
if (!chat.found) {
|
|
214
|
+
return { ok: false, message: `${action} refused: chat_id ${truncate(guid, 80)} was not found in the Messages database. Use a chat GUID from an existing conversation; this tool does not invent chats.` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const isGroup = chat.participantCount > 1;
|
|
218
|
+
const label = chat.displayName ? `"${truncate(chat.displayName, 60)}"` : guid;
|
|
219
|
+
const summary = `send a message to chat ${label} (${chat.participantCount} participant${chat.participantCount === 1 ? "" : "s"})${attachment.filePath ? " with an attachment" : ""}`;
|
|
220
|
+
|
|
221
|
+
const plan = planWrite({
|
|
222
|
+
action,
|
|
223
|
+
summary,
|
|
224
|
+
recipientCount: isGroup ? chat.participantCount : 1,
|
|
225
|
+
dryRun,
|
|
226
|
+
confirm
|
|
227
|
+
});
|
|
228
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
229
|
+
|
|
230
|
+
const result = runAppleScript(
|
|
231
|
+
buildSendToChatScript({ chatGuid: guid, text: body.text, attachmentPath: attachment.filePath }),
|
|
232
|
+
{ timeout: 60000, appName: "Messages" }
|
|
233
|
+
);
|
|
234
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result, [body.text]) };
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
message: writeSuccessMessage(action, "message sent", {
|
|
239
|
+
chat_id: guid,
|
|
240
|
+
participants: String(chat.participantCount),
|
|
241
|
+
attachment: attachment.filePath ? path.basename(attachment.filePath) : undefined
|
|
242
|
+
})
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const handles = normalizeList(args.to);
|
|
247
|
+
if (handles.length === 0) {
|
|
248
|
+
return { ok: false, message: `${action} refused: "to" (phone number or Apple ID email) or "chat_id" is required. This tool never invents recipients.` };
|
|
249
|
+
}
|
|
250
|
+
if (handles.length > MAX_RECIPIENTS) {
|
|
251
|
+
return { ok: false, message: `${action} refused: ${handles.length} recipients exceeds the per-call limit of ${MAX_RECIPIENTS}` };
|
|
252
|
+
}
|
|
253
|
+
const invalid = handles.filter((h) => !isMessagesHandle(h));
|
|
254
|
+
if (invalid.length > 0) {
|
|
255
|
+
return { ok: false, message: `${action} refused: invalid recipient(s): ${invalid.map((h) => truncate(h, 40)).join(", ")}. Use E.164 phone numbers (+15551234567) or Apple ID emails.` };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const summary = `send a message to ${handles.join(", ")} over ${serviceRaw}${attachment.filePath ? " with an attachment" : ""}`;
|
|
259
|
+
const plan = planWrite({ action, summary, recipientCount: handles.length, dryRun, confirm });
|
|
260
|
+
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
261
|
+
|
|
262
|
+
const result = runAppleScript(
|
|
263
|
+
buildSendToHandlesScript({
|
|
264
|
+
handles,
|
|
265
|
+
text: body.text,
|
|
266
|
+
service: serviceRaw,
|
|
267
|
+
attachmentPath: attachment.filePath
|
|
268
|
+
}),
|
|
269
|
+
{ timeout: 60000, appName: "Messages" }
|
|
270
|
+
);
|
|
271
|
+
if (!result.ok) return { ok: false, message: failure(action, summary, result, [body.text]) };
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
message: writeSuccessMessage(action, "message sent", {
|
|
276
|
+
to: handles.join(", "),
|
|
277
|
+
service: serviceRaw,
|
|
278
|
+
attachment: attachment.filePath ? path.basename(attachment.filePath) : undefined
|
|
279
|
+
})
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local write bridge between a short-lived MCP stdio process and the
|
|
3
|
+
* long-lived indexer daemon.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: macOS attributes an Apple event (and Contacts/Calendar
|
|
6
|
+
* access) to the *responsible process* of whoever sends it. When a host app
|
|
7
|
+
* spawns this server over stdio, the host - not node - is responsible, so a
|
|
8
|
+
* host without the Contacts/Calendars automation entitlement makes those
|
|
9
|
+
* writes fail regardless of node's own Full Disk Access. The indexer daemon
|
|
10
|
+
* is started by launchd, so node is responsible for its Apple events.
|
|
11
|
+
*
|
|
12
|
+
* The daemon therefore listens on a user-only unix socket and performs writes
|
|
13
|
+
* on behalf of stdio clients. Nothing leaves the machine: AF_UNIX socket,
|
|
14
|
+
* 0600, inside ~/.apple-tools-mcp/.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import net from "net";
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import path from "path";
|
|
20
|
+
|
|
21
|
+
export const WRITE_SOCKET_NAME = "writer.sock";
|
|
22
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 90000;
|
|
23
|
+
const MAX_FRAME_BYTES = 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
export function defaultSocketPath(home = process.env.HOME || "") {
|
|
26
|
+
return path.join(home, ".apple-tools-mcp", WRITE_SOCKET_NAME);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Start the bridge server. Safe to call when a stale socket file is left over
|
|
31
|
+
* from a crash: an unconnectable socket file is removed first.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {string} opts.socketPath
|
|
35
|
+
* @param {(tool: string, args: object) => Promise<object>|object} opts.handler
|
|
36
|
+
* @param {(msg: string) => void} [opts.log]
|
|
37
|
+
* @returns {Promise<{ socketPath: string, close: () => void }>}
|
|
38
|
+
*/
|
|
39
|
+
export function startWriteBridgeServer({ socketPath, handler, log = () => {} }) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const dir = path.dirname(socketPath);
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
reject(e);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const server = net.createServer({ allowHalfOpen: false }, (socket) => {
|
|
50
|
+
socket.setEncoding("utf8");
|
|
51
|
+
let buffer = "";
|
|
52
|
+
let handled = false;
|
|
53
|
+
|
|
54
|
+
socket.on("data", async (chunk) => {
|
|
55
|
+
if (handled) return;
|
|
56
|
+
buffer += chunk;
|
|
57
|
+
if (buffer.length > MAX_FRAME_BYTES) {
|
|
58
|
+
socket.end(JSON.stringify({ ok: false, message: "Request too large" }) + "\n");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const newline = buffer.indexOf("\n");
|
|
62
|
+
if (newline === -1) return;
|
|
63
|
+
handled = true;
|
|
64
|
+
|
|
65
|
+
let response;
|
|
66
|
+
try {
|
|
67
|
+
const request = JSON.parse(buffer.slice(0, newline));
|
|
68
|
+
response = await handler(request.tool, request.args || {});
|
|
69
|
+
} catch (e) {
|
|
70
|
+
response = { ok: false, message: `Write bridge error: ${e.message}` };
|
|
71
|
+
}
|
|
72
|
+
socket.end(JSON.stringify(response) + "\n");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
socket.on("error", () => socket.destroy());
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
server.on("error", (e) => {
|
|
79
|
+
if (e.code === "EADDRINUSE") {
|
|
80
|
+
// Either a live daemon or a stale file. Probe it: a refused connect
|
|
81
|
+
// means nothing is listening, so the file can be replaced.
|
|
82
|
+
probeSocket(socketPath)
|
|
83
|
+
.then((alive) => {
|
|
84
|
+
if (alive) {
|
|
85
|
+
reject(new Error("Another apple-tools-mcp write bridge is already listening"));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
fs.unlinkSync(socketPath);
|
|
90
|
+
} catch {
|
|
91
|
+
// best effort; listen will fail again below if it is still there
|
|
92
|
+
}
|
|
93
|
+
server.listen(socketPath, () => finish());
|
|
94
|
+
})
|
|
95
|
+
.catch(reject);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
reject(e);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const finish = () => {
|
|
102
|
+
try {
|
|
103
|
+
fs.chmodSync(socketPath, 0o600);
|
|
104
|
+
} catch {
|
|
105
|
+
// Socket files on some filesystems reject chmod; the parent dir is 0700.
|
|
106
|
+
}
|
|
107
|
+
log(`Write bridge listening at ${socketPath}`);
|
|
108
|
+
resolve({
|
|
109
|
+
socketPath,
|
|
110
|
+
close: () => {
|
|
111
|
+
try {
|
|
112
|
+
server.close();
|
|
113
|
+
} catch {
|
|
114
|
+
// already closed
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
fs.unlinkSync(socketPath);
|
|
118
|
+
} catch {
|
|
119
|
+
// already gone
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
server.listen(socketPath, finish);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @returns {Promise<boolean>} true when something is listening on the socket
|
|
131
|
+
*/
|
|
132
|
+
export function probeSocket(socketPath, timeoutMs = 1000) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
let settled = false;
|
|
135
|
+
const done = (value) => {
|
|
136
|
+
if (settled) return;
|
|
137
|
+
settled = true;
|
|
138
|
+
try {
|
|
139
|
+
client.destroy();
|
|
140
|
+
} catch {
|
|
141
|
+
// ignore
|
|
142
|
+
}
|
|
143
|
+
resolve(value);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const client = net.connect(socketPath);
|
|
147
|
+
client.setTimeout(timeoutMs);
|
|
148
|
+
client.on("connect", () => done(true));
|
|
149
|
+
client.on("error", () => done(false));
|
|
150
|
+
client.on("timeout", () => done(false));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Ask the daemon to run a write.
|
|
156
|
+
*
|
|
157
|
+
* @returns {Promise<{ delivered: boolean, response: object|null, error: string|null }>}
|
|
158
|
+
*/
|
|
159
|
+
export function requestWriteViaBridge({ socketPath, tool, args, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS }) {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
if (!socketPath) {
|
|
162
|
+
resolve({ delivered: false, response: null, error: "no socket path" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let settled = false;
|
|
167
|
+
let buffer = "";
|
|
168
|
+
const finish = (value) => {
|
|
169
|
+
if (settled) return;
|
|
170
|
+
settled = true;
|
|
171
|
+
try {
|
|
172
|
+
client.destroy();
|
|
173
|
+
} catch {
|
|
174
|
+
// ignore
|
|
175
|
+
}
|
|
176
|
+
resolve(value);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const client = net.connect(socketPath);
|
|
180
|
+
client.setEncoding("utf8");
|
|
181
|
+
client.setTimeout(timeoutMs);
|
|
182
|
+
|
|
183
|
+
client.on("connect", () => {
|
|
184
|
+
client.write(JSON.stringify({ tool, args: args || {} }) + "\n");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
client.on("data", (chunk) => {
|
|
188
|
+
buffer += chunk;
|
|
189
|
+
const newline = buffer.indexOf("\n");
|
|
190
|
+
if (newline === -1) return;
|
|
191
|
+
try {
|
|
192
|
+
finish({ delivered: true, response: JSON.parse(buffer.slice(0, newline)), error: null });
|
|
193
|
+
} catch (e) {
|
|
194
|
+
finish({ delivered: false, response: null, error: `malformed bridge response: ${e.message}` });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
client.on("end", () => {
|
|
199
|
+
if (settled) return;
|
|
200
|
+
if (buffer.trim().length > 0) {
|
|
201
|
+
try {
|
|
202
|
+
finish({ delivered: true, response: JSON.parse(buffer.trim()), error: null });
|
|
203
|
+
return;
|
|
204
|
+
} catch {
|
|
205
|
+
// fall through to the generic failure below
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
finish({ delivered: false, response: null, error: "write bridge closed without a response" });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
client.on("timeout", () => finish({ delivered: false, response: null, error: "write bridge timed out" }));
|
|
212
|
+
client.on("error", (e) => finish({ delivered: false, response: null, error: e.code || e.message }));
|
|
213
|
+
});
|
|
214
|
+
}
|