apple-tools-mcp 2.0.0 → 2.0.1

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 CHANGED
@@ -73,11 +73,13 @@ Full Disk Access covers the **read** tools. Write tools need the automation perm
73
73
 
74
74
  Write tools drive Mail, Messages, Calendar, and Contacts through AppleScript. macOS gates those Apple events behind **Automation**, not by adding `node` to the Contacts or Calendars privacy lists.
75
75
 
76
+ **One-pass first-run:** Allow **`node`** to control **Mail**, **Messages**, **Contacts**, and **Calendar** in the same Automation pass. Contacts or Calendar being allowed does **not** grant Mail or Messages — they are separate Apple Events targets. A denied Mail grant hangs `mail_send` / `mail_draft` / `mail_reply` / `mail_forward` until the client disconnects; `dry_run` never talks to Mail, so that deny is invisible until a real compose.
77
+
76
78
  **Do not add `node` via the + button in System Settings → Privacy & Security → Contacts or Calendars.** On current macOS those panes often have **no Add button**, and that instruction is not the ship-gate setup — it failed on the Mini.
77
79
 
78
80
  #### Mini ship-gate host setup
79
81
 
80
- This is the first-run flow for Contacts and Calendar **writes** on the Mac Mini. Do it on the Mini UI (or Screen Sharing to Mini), with the indexer LaunchAgent owning `node`:
82
+ This is the first-run flow for Mail, Messages, Contacts, and Calendar **writes** on the Mac Mini. Do it on the Mini UI (or Screen Sharing to Mini), with the indexer LaunchAgent owning `node`:
81
83
 
82
84
  1. Open **System Settings → Privacy & Security → Automation**.
83
85
  2. Run write prove-out with the **indexer LaunchAgent** owning `node` (`~/.apple-tools-mcp/writer.sock` / launchd). Against the tip, with the LaunchAgent up:
@@ -87,11 +89,17 @@ This is the first-run flow for Contacts and Calendar **writes** on the Mac Mini.
87
89
  ```
88
90
 
89
91
  That is the ship-gate context. An embedded agent shell, IDE terminal, or MCP host app subprocess is **not** the ship-gate host unless the write bridge is up and the work executes inside launchd-owned `node`.
90
- 3. When prompts appear, click **Allow** for **`node`** to control **Contacts** and **Calendar**. Use whatever path the LaunchAgent plist / `which node` reports — `/Users/petercoates/.local/node/bin/node` is a Mini *example* only, not a universal path. Do **not** approve the MCP client / host app that launched a short-lived stdio server.
91
- 4. **Full Disk Access** on `node` is a separate grant and covers **reads** (Mail / Messages / Calendar / AddressBook databases). Contacts and Calendar **writes** need the Automation / Apple Events grants to Contacts.app and Calendar.app.
92
+ 3. When prompts appear, click **Allow** for **`node`** to control **Mail**, **Messages**, **Contacts**, and **Calendar**. Watch the host — Allow **`node`** (the LaunchAgent binary), not any other app. Use whatever path the LaunchAgent plist / `which node` reports — `/Users/petercoates/.local/node/bin/node` is a Mini *example* only, not a universal path. Do **not** approve the MCP client / host app that launched a short-lived stdio server.
93
+ 4. **Full Disk Access** on `node` is a separate grant and covers **reads** (Mail / Messages / Calendar / AddressBook databases). Write tools need the Automation / Apple Events grants to Mail.app (`mail_send`, `mail_draft`, `mail_reply`, `mail_forward`, and the other mail writes), Messages.app (`messages_send`), Contacts.app, and Calendar.app.
92
94
  5. npm Trusted Publisher / publish tokens are unrelated to TCC. Do not confuse them with this setup.
93
95
 
94
- If you dismissed a prompt, re-open **Automation** and turn the **node → Contacts** / **node → Calendar** toggles back on. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Do not use `tccutil reset AddressBook` / `tccutil reset Calendar`, and do not add `node` via Settings **+** into the Contacts or Calendars privacy lists — those panes often have no Add button, and that is not how this ship gate is granted.
96
+ If you dismissed a prompt, re-open **Automation** and turn the **node → Mail** / **node → Messages** / **node → Contacts** / **node → Calendar** toggles back on. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Do not use `tccutil reset AddressBook` / `tccutil reset Calendar`, and do not add `node` via Settings **+** into the Contacts or Calendars privacy lists — those panes often have no Add button, and that is not how this ship gate is granted.
97
+
98
+ #### `dry_run` does not prove Mail Automation
99
+
100
+ `mail_send` / `mail_draft` / `mail_reply` / `mail_forward` with `dry_run=true` return immediately and never send Apple events to Mail — dry_run never talks to Mail. A TCC deny for **node → Mail** is therefore invisible until a real compose (`make new outgoing message`). The hang is **Automation denied**, not “Mail.app could not be reached” / “app not available”.
101
+
102
+ `tell application "Mail" to get name` can succeed while compose still hangs. The smoke test’s Mail step runs that real compose (then discards the outgoing message, or on an older daemon saves a clearly named Draft) so the deny fails **setup**, not a later production `mail_send`. The same Allow-via-prompt applies to **Messages** for `messages_send`; `messages_send` with `dry_run=true` likewise never talks to Messages.app. Smoke also live-enumerates Messages accounts (nothing is sent). **`--apply` fails closed if Mail, Messages, Contacts, or Calendar Automation is missing.**
95
103
 
96
104
  #### Which process macOS is actually asking about
97
105
 
@@ -140,7 +148,7 @@ So the supported configuration for writes is: **run the indexer daemon** ([Launc
140
148
 
141
149
  If a write is denied and no daemon is listening, the tool says so and tells you to start `apple-tools-indexer`, rather than failing with a bare AppleScript error. Contacts and Calendar denials each name their own privacy class and note that reads are unaffected.
142
150
 
143
- #### Verifying Contacts and Calendar CRUD on a Node host
151
+ #### Verifying Mail Automation plus Contacts and Calendar CRUD on a Node host
144
152
 
145
153
  The smoke test drives writes through **the same dispatcher the MCP tools use**, so when the write bridge is up the work executes inside the indexer daemon. That matters: the thing being proven is the shipping path, not the Automation rights of whatever shell you happened to type the command into.
146
154
 
@@ -155,7 +163,7 @@ The smoke test drives writes through **the same dispatcher the MCP tools use**,
155
163
 
156
164
  2. **Check out the tip / unpack the tarball** you are gating, in a short-lived directory. The global install stays untouched.
157
165
 
158
- 3. **Dry run first.** It creates, edits, and deletes nothing — but it is not a no-op: it reads your contacts from the AddressBook database and calls `calendar_list_calendars`, which is a **live Calendar.app query and therefore a real TCC touch** that can raise an Automation prompt or be denied. Because the run changes nothing, a refused listing is reported as `WARN` rather than failing the run.
166
+ 3. **Dry run first.** It creates, edits, and deletes nothing on Contacts/Calendar — but it is not a no-op: it reads your contacts from the AddressBook database, calls `calendar_list_calendars` (a **live Calendar.app query and therefore a real TCC touch**), runs a **live Mail compose** (`make new outgoing message`) because `mail_send` `dry_run` never touches Mail, and live-enumerates **Messages** accounts because `messages_send` `dry_run` never touches Messages. A refused Calendar listing, Mail compose, or Messages lookup is `WARN` on a dry run. `--apply` fails closed if **Mail, Messages, Contacts, or Calendar** Automation is still denied.
159
167
 
160
168
  ```bash
161
169
  npm run smoke:writes
@@ -169,7 +177,7 @@ The smoke test drives writes through **the same dispatcher the MCP tools use**,
169
177
  node scripts/smoke-writes.js --apply --keep # leave the test items behind
170
178
  ```
171
179
 
172
- The header prints the write path it chose (`indexer daemon via write bridge` or `in this process`), and the read path (sqlite + FDA) is reported separately from the two write paths (Contacts.app, Calendar.app), so a failure tells you which mechanism refused.
180
+ The header prints the write path it chose (`indexer daemon via write bridge` or `in this process`), and the read path (sqlite + FDA) is reported separately from the write paths (Mail.app compose, Contacts.app, Calendar.app), so a failure tells you which mechanism refused. A Mail hang or timeout is **TCC / Automation denied**, not “app not available”.
173
181
 
174
182
  **The parent process matters.** With `--apply` and **no bridge listening**, the smoke test **refuses to run** rather than executing in-process and calling the result a package failure. Running it from an embedded agent shell, an IDE terminal, or a host app's subprocess is *not* the ship-gate context on its own, because macOS attributes the Apple events to that parent. Either start the LaunchAgent (preferred, and what production clients use), or run it from **Terminal.app**, where node is the responsible process, and pass `--allow-local` to acknowledge that:
175
183
 
@@ -177,7 +185,7 @@ The header prints the write path it chose (`indexer daemon via write bridge` or
177
185
  node scripts/smoke-writes.js --apply --allow-local # only from Terminal.app / launchd
178
186
  ```
179
187
 
180
- Expected results: **PASS on the Node host with the bridge up — this is the ship gate for Contacts and Calendar writes.** On Claude Desktop with no daemon running, Contacts and Calendar CRUD are both expected to fail; that is the documented host limitation above, not a regression.
188
+ Expected results: **PASS on the Node host with the bridge up — this is the ship gate for Mail, Messages, Contacts, and Calendar writes.** `--apply` fails closed if any of those four Automation grants is missing. On Claude Desktop with no daemon running, Contacts and Calendar CRUD are both expected to fail; that is the documented host limitation above, not a regression. Mail and Messages still need their own **node → Mail** / **node → Messages** grants; a Contacts/Calendar grant does not cover them.
181
189
 
182
190
  ### 3. Configure your MCP client
183
191
 
@@ -266,7 +274,7 @@ Missing `config.json` is fine — env then the 5-minute default apply.
266
274
 
267
275
  On Mini, run the **indexer daemon**, not a sleep-pipe wrapper around `apple-tools-mcp`. Claude Desktop and other clients still attach via short-lived stdio MCP (`npx -y apple-tools-mcp` or the global `apple-tools-mcp` bin).
268
276
 
269
- The daemon does two jobs: it refreshes the vector index, and it serves the **write bridge** at `~/.apple-tools-mcp/writer.sock` so stdio clients can perform Contacts/Calendar writes that their host app cannot be granted (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)). When macOS prompts, Allow **`node`** (the LaunchAgent binary) to control Contacts.app and Calendar.app. Do not approve the MCP client / host app that launched a short-lived stdio server, and do not try to add `node` via **+** in the Contacts or Calendars privacy lists.
277
+ The daemon does two jobs: it refreshes the vector index, and it serves the **write bridge** at `~/.apple-tools-mcp/writer.sock` so stdio clients can perform Mail / Messages / Contacts / Calendar writes that their host app cannot be granted (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)). When macOS prompts, Allow **`node`** (the LaunchAgent binary) to control Mail.app, Messages.app, Contacts.app, and Calendar.app. Do not approve the MCP client / host app that launched a short-lived stdio server, and do not try to add `node` via **+** in the Contacts or Calendars privacy lists.
270
278
 
271
279
  The bridge is created before the daemon touches the vector index, so writes stay available even when the index is missing, locked, or mid-rebuild. Confirm it after an upgrade with `ls -l ~/.apple-tools-mcp/writer.sock` (it should be a `srw-------` socket); the daemon removes it on shutdown.
272
280
 
@@ -438,12 +446,20 @@ Supported identifiers:
438
446
  |------|-----------|--------------|
439
447
  | `calendar_list_calendars` | none | none (read-only helper) |
440
448
  | `calendar_add` | `calendar_name` (required), `title` (required), `start` (required), `end`, `all_day`, `location`, `notes`, recurrence args, `alerts_minutes_before[]` | none |
441
- | `calendar_edit` | `event_id` (required) plus any of `title`, `start`, `end`, `location`, `notes`, recurrence args, `alerts_minutes_before[]`, `replace_alerts` | none |
442
- | `calendar_remove` | `event_id` (required) | **`confirm` required** |
449
+ | `calendar_edit` | `event_id` (required), optional `eventkit_id` from add, plus any of `title`, `start`, `end`, `location`, `notes`, recurrence args, `alerts_minutes_before[]`, `replace_alerts` | none |
450
+ | `calendar_remove` | `event_id` (required), optional `eventkit_id` from add, optional `calendar_name` | **`confirm` required** |
443
451
  | `calendar_rsvp` | `event_id` (required), `response` (`accept` \| `decline` \| `tentative`), `attendee_email` | none |
444
452
 
445
453
  Events are addressed by their **iCalendar UID**, reported as `Event ID` by `calendar_date` and returned by `calendar_add`. Run `calendar_list_calendars` first so new events land on the intended calendar instead of the default one.
446
454
 
455
+ `calendar_add`, `calendar_edit`, and `calendar_remove` share the same write-bridge RPC (`{ tool, args }` into launchd-owned node). They do not use a different socket or calendar account. Calendar.app’s dictionary has **`delete` only** — there is no `remove` or `move to trash`.
456
+
457
+ Non-recurring `calendar_add` **must** create through **EventKit** and print `via: EventKit` plus `eventkit_id`. Mini `cd74071`: EventKit had `eventKitCalendars=1` but `default=[id NSTaggedPointerString]` — JXA `String(title)` prints the ObjC class, not the calendar name, so the title match missed the only writable calendar. Titles and `calendarIdentifier` are `ObjC.unwrap`d; a single writable calendar or `defaultCalendarForNewEvents` is used when the name does not match. There is **no AppleScript fallback** for non-recurring add.
458
+
459
+ `calendar_edit` and `calendar_remove` take `eventkit_id` from add (`calendarUUID:eventUUID` on Mini). writeOnly EventKit (`status=4`) can create and read ids off the saved `EKEvent`, but **cannot re-query** via `eventWithIdentifier` — Mini `2cdf44d` failed add on that post-save lookup. Add therefore returns ids from the in-memory event. The indexer write-bridge keeps a long-lived EventKit osascript session that caches the `EKEvent` so edit/remove call `saveEvent` / `removeEvent` without a fetch. When `eventkit_id` is present and the session misses, Calendar.app uid lookup is skipped so iCloud cannot hang. `ETIMEDOUT` / `-1712` is **`timeout`**, never TCC. Failures print `osascript kind=… error=… codes=…`. `--apply` fails closed unless add printed `via: EventKit` and `eventkit_id`.
460
+
461
+ `--apply` prefers an **On My Mac** calendar when EventKit lists one. `--calendar=` still wins. The Mini host’s first writable calendar named **Calendar** (no On My Mac / iCloud label) is fine if EventKit create actually runs.
462
+
447
463
  Like Contacts, calendar writes go through Calendar.app rather than writing `Calendar.sqlitedb` directly. Reads query that database for speed, but edits must go through the app so iCloud sync, invitations, and alarms behave correctly.
448
464
 
449
465
  **Supported recurrence patterns.** Either pass structured arguments or a raw `recurrence` RRULE:
@@ -526,12 +542,13 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
526
542
 
527
543
  The message names which process macOS was actually asking about. Work through it in this order:
528
544
 
529
- 1. **Is the indexer daemon running?** `pgrep -fl apple-tools-indexer`. If not, start it — the daemon is the supported host for Contacts and Calendar writes (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)).
530
- 2. **Did you approve the Automation prompts for `node`?** Open System Settings → Privacy & Security → **Automation** and confirm **node** (the LaunchAgent binary — whatever path the plist / `which node` reports; `/Users/petercoates/.local/node/bin/node` is a Mini *example* only) is allowed to control Contacts and Calendar. Do **not** approve the MCP client / host app that launched a short-lived stdio server, and do **not** try to add `node` via **+** in the Contacts or Calendars privacy lists — those panes often have no Add button. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Full Disk Access is a separate read grant; npm Trusted Publisher / tokens are unrelated.
545
+ 1. **Is the indexer daemon running?** `pgrep -fl apple-tools-indexer`. If not, start it — the daemon is the supported host for Mail, Messages, Contacts, and Calendar writes (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)).
546
+ 2. **Did you approve the Automation prompts for `node`?** Open System Settings → Privacy & Security → **Automation** and confirm **node** (the LaunchAgent binary — whatever path the plist / `which node` reports; `/Users/petercoates/.local/node/bin/node` is a Mini *example* only) is allowed to control **Mail**, **Messages**, Contacts, and Calendar. Watch the host — Allow **`node`**, not any other app. Contacts or Calendar being allowed does **not** grant Mail. A `mail_send` hang is **Automation denied**, not “Mail.app could not be reached”; `dry_run` never talks to Mail so it cannot detect this. Do **not** approve the MCP client / host app that launched a short-lived stdio server, and do **not** try to add `node` via **+** in the Contacts or Calendars privacy lists — those panes often have no Add button. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Full Disk Access is a separate read grant; npm Trusted Publisher / tokens are unrelated.
531
547
  3. **Is the daemon's node binary the one with Full Disk Access?** LaunchAgents do not inherit your shell `PATH`; confirm the plist points at the same path `which node` reports.
532
548
  4. **Is it actually the write path?** Contacts or Calendar *reads* failing with `EPERM` is a Full Disk Access / attribution problem, not the entitlement gap — fix FDA for the responsible process. Contacts or Calendar *CRUD* failing with no prompt under Claude Desktop is the [documented host limitation](#reads-and-writes-are-different-mechanisms): Claude.app carries neither the addressbook nor the calendars entitlement. Start the daemon and the write succeeds through the bridge.
533
549
  5. **"Contacts.app / Calendar.app could not be reached"** with the app clearly installed is an **Automation / responsible-process** failure, not a missing app — macOS reports a refused Apple event as `-1728` / "can't get application". The tool says so and points at the bridge. Start the LaunchAgent, or run from Terminal.app and approve the Automation prompt.
534
- 6. **Prove the host itself works** with `node scripts/smoke-writes.js --apply` on the Node host with the LaunchAgent running; it routes through the bridge and separates the read and write mechanisms for you.
550
+ 6. **`calendar_remove` FAIL after add/edit PASS** is a delete-path failure, not missing Calendar Automation. Same write-bridge RPC. Read the `osascript kind=/error=` line: `kind=timeout` + `ETIMEDOUT` / `-1712` is an iCloud/CalDAV hang or Calendar confirmation dialog — **not** TCC. `EVENTKIT_NOT_FOUND status=4` is writeOnly EventKit failing to see an AppleScript-created event (add must go through EventKit). `-1743` / `-10004` is a real Automation deny. Paste that line if it still FAILs.
551
+ 7. **Prove the host itself works** with `node scripts/smoke-writes.js --apply` on the Node host with the LaunchAgent running; it routes through the bridge and separates the read and write mechanisms for you.
535
552
 
536
553
  ### A write returned "CONFIRMATION REQUIRED"
537
554
 
package/index.js CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  executeWriteToolLocally
30
30
  } from "./lib/writeTools.js";
31
31
  import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
32
+ import { closeEventKitSession, ensureEventKitSession } from "./lib/eventKitSession.js";
33
+ import { EVENTKIT_JXA_HELPERS } from "./lib/calendarWrite.js";
32
34
 
33
35
  const PACKAGE_VERSION = JSON.parse(
34
36
  fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
@@ -83,6 +85,7 @@ const WRITE_SOCKET_PATH = defaultSocketPath();
83
85
  let writeBridge = null;
84
86
 
85
87
  function stopWriteBridge() {
88
+ closeEventKitSession();
86
89
  if (!writeBridge) return;
87
90
  try {
88
91
  writeBridge.close();
@@ -99,6 +102,7 @@ async function startWriteBridge() {
99
102
  handler: (tool, args) => executeWriteToolLocally(tool, args),
100
103
  log: (msg) => console.error(msg)
101
104
  });
105
+ ensureEventKitSession({ helpers: EVENTKIT_JXA_HELPERS });
102
106
  } catch (e) {
103
107
  console.error(`Write bridge unavailable: ${e.message}. Writes will run in each MCP process.`);
104
108
  }
@@ -24,6 +24,7 @@ export const DEFAULT_SCRIPT_TIMEOUT_MS = 30000;
24
24
  export const NOT_FOUND_SENTINELS = [
25
25
  "MESSAGE_NOT_FOUND",
26
26
  "EVENT_NOT_FOUND",
27
+ "EVENTKIT_NOT_FOUND",
27
28
  "CONTACT_NOT_FOUND",
28
29
  "CALENDAR_NOT_FOUND",
29
30
  "CHAT_NOT_FOUND",
@@ -69,11 +70,27 @@ const APP_MISSING_SIGNATURES = [
69
70
 
70
71
  // osascript itself is missing: not macOS, or a stripped PATH. Never an
71
72
  // attribution problem, because nothing ran.
73
+ //
74
+ // Do **not** match a bare "spawnSync osascript" here. A TCC-denied Mail
75
+ // compose hangs until spawnSync times out (`spawnSync osascript ETIMEDOUT`);
76
+ // that string also contains "spawnSync osascript" and must not be reported
77
+ // as "Mail.app could not be reached".
72
78
  const OSASCRIPT_MISSING_SIGNATURES = [
73
- "spawnsync osascript",
79
+ "spawnsync osascript enoent",
74
80
  "enoent"
75
81
  ];
76
82
 
83
+ // Hung Apple events. Mini diagnosis: `tell Mail to get name` returns, but
84
+ // `make new outgoing message` blocks until timeout when node → Mail
85
+ // Automation is denied. That hang is a TCC deny, not a missing app.
86
+ const TIMEOUT_SIGNATURES = [
87
+ "etimedout",
88
+ "timed out after",
89
+ "-1712",
90
+ "appleevent timed out",
91
+ "apple event timed out"
92
+ ];
93
+
77
94
  /**
78
95
  * Standard install locations for the apps this package automates.
79
96
  * Ventura and later keep the first-party apps in /System/Applications.
@@ -98,7 +115,7 @@ export function appBundleInstalled(appName, existsFn = fs.existsSync) {
98
115
  * @param {string} message
99
116
  * @param {object} [context]
100
117
  * @param {boolean|null} [context.appInstalled] - whether the target app exists
101
- * @returns {"tcc"|"not_found"|"attribution"|"app_unavailable"|"unknown"}
118
+ * @returns {"tcc"|"timeout"|"not_found"|"attribution"|"app_unavailable"|"unknown"}
102
119
  */
103
120
  export function classifyAppleScriptError(message, context = {}) {
104
121
  const text = String(message || "").toLowerCase();
@@ -106,7 +123,21 @@ export function classifyAppleScriptError(message, context = {}) {
106
123
  if (text.includes(sentinel.toLowerCase())) return "not_found";
107
124
  }
108
125
  if (OSASCRIPT_MISSING_SIGNATURES.some((sig) => text.includes(sig))) return "app_unavailable";
126
+ // ETIMEDOUT / -1712 is a hang, not a TCC deny. Mail/Messages still map
127
+ // this kind to Automation guidance (compose hang = grant missing).
128
+ // calendar_remove must never print Calendar-denied copy for ETIMEDOUT alone.
129
+ if (TIMEOUT_SIGNATURES.some((sig) => text.includes(sig))) return "timeout";
109
130
  if (TCC_SIGNATURES.some((sig) => text.includes(sig))) return "tcc";
131
+ // Calendar delete of a detached event specifier often returns
132
+ // "Can't get event … (-1728)". That is a missing object, not a missing app.
133
+ // Check before the generic -1728 / "can't get application" path.
134
+ if (
135
+ text.includes("can't get event") ||
136
+ text.includes("can't get calendar") ||
137
+ text.includes("can't get theevent")
138
+ ) {
139
+ return "not_found";
140
+ }
110
141
  if (APP_MISSING_SIGNATURES.some((sig) => text.includes(sig))) {
111
142
  // The app is on disk, so "can't get application" means macOS refused to
112
143
  // let the responsible process drive it - an Automation / attribution
@@ -120,6 +151,75 @@ export function isTccDenial(message) {
120
151
  return classifyAppleScriptError(message) === "tcc";
121
152
  }
122
153
 
154
+ /**
155
+ * Real Automation / privacy deny codes — not a hung Calendar.app delete.
156
+ *
157
+ * Mini calendar_remove: add/edit succeed (Automation granted), then delete
158
+ * hits spawnSync ETIMEDOUT / AppleEvent -1712. That is an iCloud/CalDAV
159
+ * hang or a confirmation dialog, not -1743 / -10004. Callers must not
160
+ * rewrite that as CALENDAR_TCC_GUIDANCE.
161
+ */
162
+ const HARD_TCC_SIGNATURES = [
163
+ "-1743",
164
+ "-10004",
165
+ "-25211",
166
+ "not authorized to send apple events",
167
+ "not allowed to send apple events",
168
+ "is not allowed assistive access",
169
+ "operation not permitted",
170
+ "not permitted to access",
171
+ "access to contacts",
172
+ "access to calendars",
173
+ "privacy settings",
174
+ "errae eventnotpermitted",
175
+ "errAEEventNotPermitted".toLowerCase()
176
+ ];
177
+
178
+ export function isHardTccDenial(message) {
179
+ const text = String(message || "").toLowerCase();
180
+ return HARD_TCC_SIGNATURES.some((sig) => text.includes(sig));
181
+ }
182
+
183
+ const APPLEEVENT_CODES = [
184
+ "-1743",
185
+ "-10004",
186
+ "-25211",
187
+ "-1712",
188
+ "-1728",
189
+ "-600",
190
+ "-10810",
191
+ "-1708",
192
+ "-1719",
193
+ "-10025",
194
+ "-2700"
195
+ ];
196
+
197
+ /**
198
+ * Pull known AppleEvent / spawn codes out of osascript stderr so smoke
199
+ * can print them instead of guessing TCC.
200
+ */
201
+ export function extractAppleEventCodes(message) {
202
+ const text = String(message || "");
203
+ const found = [];
204
+ for (const code of APPLEEVENT_CODES) {
205
+ if (text.includes(code) && !found.includes(code)) found.push(code);
206
+ }
207
+ if (/etimedout/i.test(text) && !found.includes("ETIMEDOUT")) found.push("ETIMEDOUT");
208
+ return found;
209
+ }
210
+
211
+ /**
212
+ * One-line osascript diagnostic. Always include this on calendar_remove
213
+ * failure so Mini --apply stops looking like a TCC deny.
214
+ */
215
+ export function formatOsascriptDiagnostic(result, source = "osascript") {
216
+ const raw = String(result && result.error ? result.error : "").replace(/\s+/g, " ").trim();
217
+ const clipped = raw.length > 360 ? `${raw.slice(0, 360)}...` : raw;
218
+ const kind = (result && result.kind) || "unknown";
219
+ const codes = extractAppleEventCodes(raw);
220
+ return `${source} kind=${kind} error=${clipped || "(empty)"}${codes.length ? ` codes=${codes.join(",")}` : ""}`;
221
+ }
222
+
123
223
  /**
124
224
  * Guidance attached to TCC denials. Names the host constraint without
125
225
  * claiming the package can grant another app's entitlements.
@@ -168,11 +268,35 @@ export const ATTRIBUTION_GUIDANCE =
168
268
  "under launchd-owned node, or run this server from a parent that can hold Automation access (Terminal.app) and approve the prompt.";
169
269
 
170
270
  /**
171
- * @param {"contacts"|"calendar"|string} source
271
+ * Mail writes are gated by Automation → Mail for the responsible process.
272
+ * A hang or timeout on compose/send is that deny — not "Mail.app missing".
273
+ * dry_run never sends Apple events to Mail, so it cannot detect the grant.
274
+ */
275
+ export const MAIL_TCC_GUIDANCE =
276
+ "macOS denied Mail automation (Apple Events to Mail.app). A hang or timeout on compose or send is a TCC / Automation deny for node → Mail, " +
277
+ "not Mail.app missing or unavailable. dry_run never talks to Mail, so it cannot detect this grant. " +
278
+ "Allow node in System Settings → Privacy & Security → Automation for Mail (same Allow-via-prompt as Contacts and Calendar — do not add node via + in a privacy list). " +
279
+ "A Contacts or Calendar grant does not include Mail. " +
280
+ "Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Mail writes execute under node.";
281
+
282
+ /**
283
+ * Messages writes are a separate Automation target from Mail / Contacts / Calendar.
284
+ */
285
+ export const MESSAGES_TCC_GUIDANCE =
286
+ "macOS denied Messages automation (Apple Events to Messages.app). A hang or timeout on send is a TCC / Automation deny for node → Messages, " +
287
+ "not Messages.app missing or unavailable. dry_run never talks to Messages, so it cannot detect this grant. " +
288
+ "Allow node in System Settings → Privacy & Security → Automation for Messages (same Allow-via-prompt as Mail, Contacts, and Calendar — do not add node via + in a privacy list). " +
289
+ "A Contacts or Calendar grant does not include Messages. " +
290
+ "Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Messages writes execute under node.";
291
+
292
+ /**
293
+ * @param {"contacts"|"calendar"|"mail"|"messages"|string} source
172
294
  */
173
295
  export function tccGuidanceFor(source) {
174
296
  if (source === "contacts") return CONTACTS_TCC_GUIDANCE;
175
297
  if (source === "calendar") return CALENDAR_TCC_GUIDANCE;
298
+ if (source === "mail") return MAIL_TCC_GUIDANCE;
299
+ if (source === "messages") return MESSAGES_TCC_GUIDANCE;
176
300
  return TCC_GUIDANCE;
177
301
  }
178
302
 
@@ -184,9 +308,9 @@ export function tccGuidanceFor(source) {
184
308
  * @returns {{ ok: boolean, output: string, error: string|null, kind: string|null }}
185
309
  */
186
310
  export function runAppleScript(script, options = {}) {
187
- const { timeout = DEFAULT_SCRIPT_TIMEOUT_MS, appName = null } = options;
311
+ const { timeout = DEFAULT_SCRIPT_TIMEOUT_MS, appName = null, language = null } = options;
188
312
  try {
189
- const output = safeOsascript(script, { timeout });
313
+ const output = safeOsascript(script, { timeout, language });
190
314
  return { ok: true, output: (output || "").trim(), error: null, kind: null };
191
315
  } catch (e) {
192
316
  const message = e && e.message ? e.message : String(e);