apple-tools-mcp 2.0.10 → 2.1.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
@@ -77,7 +77,7 @@ Write tools drive Mail, Messages, Calendar, and Contacts through AppleScript. ma
77
77
 
78
78
  #### First install and upgrade: `apple-tools-mcp permissions`
79
79
 
80
- Run this **after first global install** and **after upgrade** when write surfaces are present or change. It probes **`process.execPath`** (the `node` running the command) so macOS can pop **Allow** dialogs for Contacts, Calendar, Mail, and Messages in one sitting. You click **Allow**; the command cannot grant silently. Already-granted surfaces report OK without another click. Missing grants print a report and the process **exits non-zero** (fail closed).
80
+ Run this **after first global install** and **after upgrade** when write surfaces are present or change. It probes **`process.execPath`** (the `node` running the command) so macOS can pop **Allow** dialogs for Contacts, Calendar, Mail, Messages, and System Events in one sitting. You click **Allow**; the command cannot grant silently. Already-granted surfaces report OK without another click. Missing grants print a report and the process **exits non-zero** (fail closed).
81
81
 
82
82
  Use the **same `node` the product uses** — not the MCP host app. Always invoke so **`process.execPath`** is that node. A bare `apple-tools-mcp` (or a path like `~/.nvm/versions/node/v22.21.1/bin/apple-tools-mcp`) can have a shebang that starts a **different** node; Allow dialogs attach to `execPath`, not the CLI path you typed.
83
83
 
@@ -98,15 +98,15 @@ If the invoked CLI path and `process.execPath` differ, the command prints a **WA
98
98
  - **Mini:** `/Users/petercoates/.local/node/bin/node` (global npm / indexer LaunchAgent)
99
99
  - **MacBook:** `/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node` (Claude’s nvm `node`, **not** Homebrew)
100
100
 
101
- **MacBook (default for `permissions`):** run from **Terminal.app**. Click **Allow** for the **printed `process.execPath`**. Confirm **System Settings → Privacy & Security → Automation** for that node → Contacts, Calendar, Mail, and Messages. Do **not** start `apple-tools-indexer` — the always-on indexer / write bridge is a **Mini** install only.
101
+ **MacBook (default for `permissions`):** run from **Terminal.app**. Click **Allow** for the **printed `process.execPath`**. Confirm **System Settings → Privacy & Security → Automation** for that node → Contacts, Calendar, Mail, Messages, and System Events. Do **not** start `apple-tools-indexer` — the always-on indexer / write bridge is a **Mini** install only.
102
102
 
103
103
  **Mini:** use the [Mini ship-gate](#mini-ship-gate-host-setup) path (LaunchAgent-owned node / `writer.sock`). Missing-grant copy mentions the write bridge only when that socket is present.
104
104
 
105
- On the host UI (Mini Screen Sharing or MacBook local), open **System Settings → Privacy & Security → Automation**, then run the command. Click **Allow** for **`node`** → Contacts, Calendar, Mail, and Messages. npm `postinstall` only **prints a reminder** — it does not run the probes unattended.
105
+ On the host UI (Mini Screen Sharing or MacBook local), open **System Settings → Privacy & Security → Automation**, then run the command. Click **Allow** for **`node`** → Contacts, Calendar, Mail, Messages, and System Events. npm `postinstall` only **prints a reminder** — it does not run the probes unattended.
106
106
 
107
- This is a real Apple Events pass: Mail uses `make new outgoing message` (compose then discard; nothing is sent). Messages enumerates accounts (nothing is sent). `dry_run` of `mail_send` / `messages_send` never talks to those apps and **does not count**. Contacts creates and deletes a throwaway person in-script. Calendar lists calendars only (no leftover events).
107
+ This is a real Apple Events pass: Mail uses `make new outgoing message` (compose then discard; nothing is sent). Messages enumerates accounts (nothing is sent). `dry_run` of `mail_send` / `messages_send` never talks to those apps and **does not count**. Contacts creates and deletes a throwaway person in-script. Calendar lists calendars only (no leftover events). System Events is asked for its process name (nothing is typed) so the keystroke compose path can pop **Allow**.
108
108
 
109
- **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.
109
+ **One-pass first-run:** Allow **`node`** to control **Mail**, **Messages**, **Contacts**, **Calendar**, and **System Events** in the same Automation pass. Contacts or Calendar being allowed does **not** grant Mail, Messages, or System Events — 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.
110
110
 
111
111
  **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.
112
112
 
@@ -122,7 +122,7 @@ This is the first-run flow for Mail, Messages, Contacts, and Calendar **writes**
122
122
  ```
123
123
 
124
124
  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`.
125
- 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.
125
+ 3. When prompts appear, click **Allow** for **`node`** to control **Mail**, **Messages**, **Contacts**, **Calendar**, and **System Events** (needed for `mail_send` / `mail_draft` keystrokes under the LaunchAgent). 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.
126
126
  4. **Keep Contacts, Mail, and Messages running** on the write host (leave the apps open; do not quit them). Cold `tell application "Contacts"` under launchd often fails with `-600` / “application isn't running” instead of auto-launching — that is **not** an Automation deny. The write path launches Contacts.app before CRUD, but write reliability still requires those three apps to stay running. **Calendar does not need to stay open** (Calendar writes go through EventKit).
127
127
  5. **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.
128
128
  6. npm Trusted Publisher / publish tokens are unrelated to TCC. Do not confuse them with this setup.
@@ -308,7 +308,7 @@ Missing `config.json` is fine — env then the 5-minute default apply.
308
308
 
309
309
  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).
310
310
 
311
- 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.
311
+ 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, Calendar.app, **and System Events** (mail_send / mail_draft type the body via keystrokes). 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.
312
312
 
313
313
  **Keep Contacts.app, Mail.app, and Messages.app running all the time** on the Mini (and on any MacBook that does local writes). Quitting them makes Apple Events to those apps unreliable (`-600` / “application isn't running”), which is a cold-launch miss — not a missing Automation grant. The Contacts write path launches Contacts.app before add/edit/remove, but do not rely on that instead of leaving the apps open. **Calendar.app does not need to stay open**; Calendar writes use EventKit.
314
314
 
@@ -464,19 +464,23 @@ Two arguments are available on **every** write tool:
464
464
 
465
465
  `body_format: "html"` uses the same compose path with a tag-stripped body pasted into Mail's native editor. It does **not** set Mail's `content` or `html content` — those setters cite-wrap the whole message (`>` prefixes and `<blockquote type="cite">`, same iOS purple bar). Mail may still generate its own HTML alternative from the native compose. The default is plain text.
466
466
 
467
- **Compose is not quoted.** `mail_send` / `mail_draft` (plain and html) call `make new outgoing message` **without** AppleScript `content:` so `newMessage` is a real Mail object, then paste the body into the compose window (System Events). Mail's `mailto` command was shipped in 2.0.4 and **fails on Mini/MacBook Mail**: it does not return an outgoing message (`newMessage` undefined, AppleScript **-2753**). On current Mail (Ventura+, FB11734014) `content` / `html content` store the body as a citation: every plain-text line prefixed with `>`, plus a `multipart/alternative` HTML part wrapped in `<blockquote type="cite">`. Desktop Mail often hides the bar with inline styles; iOS Mail paints the whole body purple with a left quote bar — even when the subject is not `Re:`/`Fwd:` and there is no `In-Reply-To`. Reply and forward still quote the original, which is expected.
467
+ **Compose is not quoted.** `mail_send` / `mail_draft` (plain and html) call `make new outgoing message` **without** AppleScript `content:` so `newMessage` is a real Mail object, then type the body into the compose window (System Events keystrokes). Mail's `mailto` command was shipped in 2.0.4 and **fails on Mini/MacBook Mail**: it does not return an outgoing message (`newMessage` undefined, AppleScript **-2753**). On current Mail (Ventura+, FB11734014) `content` / `html content` store the body as a citation: every plain-text line prefixed with `>`, plus a `multipart/alternative` HTML part wrapped in `<blockquote type="cite">`. Desktop Mail often hides the bar with inline styles; iOS Mail paints the whole body purple with a left quote bar — even when the subject is not `Re:`/`Fwd:` and there is no `In-Reply-To`. Reply and forward still quote the original, which is expected (those paths still prepend via `content`; fixing reply/forward cite-wrap is out of scope for 2.1.0).
468
468
 
469
- Paste uses System Events, so **node needs Accessibility** (Privacy & Security → Accessibility) in addition to Automation → Mail. That Accessibility deny is not a Mail Automation deny (`-1743` / `-10004`). Body focus uses `text area` / `scroll area` / `text field` and `UI element whose role is "AXWebArea"` — never the System Events class `web area`, which does not compile on macOS 26.x (**-2741**, “Expected class name but found identifier”). The pre-paste focus read uses `first UI element whose focused is true` and `value of attribute "AXFocusedUIElement"` — never the compound `focused UI element`, which does not compile on System Events 26.x (**-2741** on MacBook 26.1, **-2740** on Mini 26.6.2).
469
+ Keystrokes use System Events, so **node needs Accessibility** (Privacy & Security → Accessibility) **and** Automation → Mail **and** Automation → System Events. Run `apple-tools-mcp permissions` after first install or upgrade so **node → System Events** pops **Allow** in the same sitting as Mail / Messages / Contacts / Calendar. An Accessibility / System Events deny is not a Mail Automation deny (`-1743` / `-10004`).
470
470
 
471
- **Compose body focus (2.0.8).** After `make new outgoing message`, To and Subject are AppleScript properties (never paste). Cmd-V runs only after the caret leaves the header: a tall `AXWebArea` (HTML compose body), a tall `text area` (plain compose; short To/Subject fields are skipped), Subject then Tab, or a click in the lower two-thirds of the compose window. If focus is still a header field, the tool raises `BODY_FOCUS_FAILED` and does **not** paste. After paste it checks To/Cc/Bcc counts, the exact subject, and that the native body contains the intended text. A mismatch is `BODY_PASTE_MISDIRECTED`: the outgoing message is deleted and nothing is sent. Quote-prefix Sent prove and Sent/Outbox verify stay gated on this paste-focus contract — a MacBook 2.0.7 live `mail_send` with Accessibility allowed still hit `BODY_PASTE_MISDIRECTED` when Cmd-V landed in To/Subject (`ATP-207-SENT-PROVE-20260921-084048`).
471
+ **Compose body focus (2.0.8).** After `make new outgoing message`, To and Subject are AppleScript properties (never paste). Cmd-V used to run only after an AX window walker left the header. Quote-prefix Sent prove and Sent/Outbox verify stay gated on fail-closed body/header checks — a MacBook 2.0.7 live `mail_send` with Accessibility allowed still hit `BODY_PASTE_MISDIRECTED` when Cmd-V landed in To/Subject (`ATP-207-SENT-PROVE-20260921-084048`).
472
472
 
473
- **macOS 26 focus compile (2.0.9).** Dual-host Sent prove on `@2.0.8` never reached paste: `atmSafeToPaste` used bare `focused UI element`, which System Events 26.x rejects at compile. The 2.0.8 fail-closed gates stay; only the focus *query* changed to the forms hosts compiled.
473
+ **macOS 26 focus compile (2.0.9).** Dual-host Sent prove on `@2.0.8` never reached paste: `atmSafeToPaste` used bare `focused UI element`, which System Events 26.x rejects at compile.
474
474
 
475
- **AppleScript reserved identifiers (2.0.10).** Dual-host Sent prove on `@2.0.9` compiled past the focused-UI fix, then failed in `atmFocusMailBody`: `set {tw, th} to size of atmElem` → **-2741** Expected expression but found `th` (AppleScript reserved ordinal). Size/position lists now use `atmW`/`atmH` (hosts confirmed) and other `atm*` names — never `th`/`tw`/`st`/`nd`/`rd`/`to`/`by`/`id` in destructuring. 2.0.8/2.0.9 fail-closed paste-focus and 2.0.9 AXFocusedUIElement / `whose focused is true` are unchanged.
475
+ **AppleScript reserved identifiers (2.0.10).** Dual-host Sent prove on `@2.0.9` compiled past the focused-UI fix, then failed in `atmFocusMailBody`: `set {tw, th} to size of atmElem` → **-2741** Expected expression but found `th` (AppleScript reserved ordinal).
476
476
 
477
- **mail_send success is Sent/Outbox verify (2.0.7).** AppleScript `send` returning without throw is not enough. After a real send the tool looks in **Sent** and **Outbox** and reports success only if the message is there. The success text names the delivery state (`mailbox: sent` + `delivery: sent`, or `mailbox: outbox` + `delivery: outbox` while still sending). If verify misses, the tool returns a failure (`isError`) — not success. Hang/timeout recover matches **To + subject** (and Message-ID when compose captured one). It never matches subject alone (short subjects like `test` are unsafe). `from` / account selection is out of scope; Mail's default From is used.
477
+ **Keystroke compose (2.1.0).** macOS 26 System Events reports `windows of process Mail = 0`, so the AX window walker cannot focus a tall `AXWebArea`. Fresh compose **Tabs into the body** (max Tab×6 as proven on Mini) and **types the body with Return between paragraphs**. Clipboard Cmd-V is only a fallback if a *readable* AX value is still missing the body needle. Fail-closed: missing AX body focus or changed To/Cc/Bcc/subject is `BODY_FOCUS_FAILED` / `BODY_PASTE_MISDIRECTED`; the outgoing message is deleted and nothing is sent. Apple events are wrapped in `with timeout`; hung `osascript` is **SIGKILL**'d so a wedge fails fast instead of sitting ~60s. If the Mini write-bridge / indexer daemon cannot drive System Events, `mail_send` returns `unsupported: true` so the stdio client falls back **in-process** — it does **not** hang with `MAIL_SEND_TIMEOUT_GUIDANCE` for a message that never sent.
478
478
 
479
- **Manual prove (2.0.10 on the Mac host):** AppleScript for paste-focus **compiles** (no `-2741` on `th`; no `-2741`/`-2740` on `focused UI element`). Then `mail_send` a short plain message and a short `body_format: "html"` message whose body looks like ordinary paragraphs (for example `<p>Quick note</p>`), neither with a `Re:`/`Fwd:` subject. The caret must land in the **body** (not To/Subject): no `BODY_FOCUS_FAILED` / `BODY_PASTE_MISDIRECTED`, and the intended text must be in the native body before send. Inspect each Sent `.emlx`: the text/plain part must not prefix every body line with `>`, and any HTML alternative must not wrap the whole body in `<blockquote type="cite">`. Compose must succeed (no `-2753` / undefined `newMessage`, no **-2741** / **-2740** on body focus). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
479
+ **AX body-focus oracle (2.1.1).** Dual-host Mail 16 / macOS 26 showed that Mail AppleScript `content of` the outgoing message is **not** a live oracle for UI-typed/pasted text: `set content` reads back, but after Tab + keystroke/paste into the UI body `content of` stays empty (`typedSeen=false`, bodyLen 0) even when typing still produces a plain Sent `.emlx`. `@2.1.0` calibrated Tab against that property and fail-closed with `BODY_FOCUS_FAILED`. 2.1.1 Tabs until the focused AX role is a body field (`AXWebArea` / non-header `AXTextArea` via `AXFocusedUIElement` / `first UI element whose focused is true` — never `focused UI element`, never `windows of process Mail`). Cmd-V runs only when that AX **value** is readable and still missing the needle. An empty AX value is not a miss (Mail 16 WebArea); header checks still fail-closed. Ship prove that the body landed remains the Sent `.emlx` after send (2.0.7 To+subject verify). Do not “fix Tab count.”
480
+
481
+ **mail_send success is Sent/Outbox verify (2.0.7).** AppleScript `send` returning without throw is not enough. After a real send the tool looks in **Sent** and **Outbox** and reports success only if the message is there. The success text names the delivery state (`mailbox: sent` + `delivery: sent`, or `mailbox: outbox` + `delivery: outbox` while still sending). If verify misses, the tool returns a failure (`isError`) — not success. Hang/timeout recover matches **To + subject** (and Message-ID when compose captured one). It never matches subject alone (short subjects like `test` are unsafe). `from` / account selection is out of scope; Mail's default From is used. `dry_run` / confirm-blocked writes stay `ok: false`, `planned: true`, `delivered: false`.
482
+
483
+ **Manual prove (2.1.1 on the Mac host):** After `apple-tools-mcp permissions` reports System Events granted, `mail_send` a short **multiline** plain message and a short `body_format: "html"` message whose body looks like ordinary paragraphs (for example `<p>Quick note</p>`), neither with a `Re:`/`Fwd:` subject. The caret must land in the **body** (not To/Subject): no `BODY_FOCUS_FAILED` / `BODY_PASTE_MISDIRECTED`. Inspect each Sent `.emlx`: the text/plain part must contain the intended text (newlines preserved) and must not prefix every body line with `>`, and any HTML alternative must not wrap the whole body in `<blockquote type="cite">`. Compose must succeed (no `-2753` / undefined `newMessage`). On Mini, prove the production path (write-bridge with node → System Events allowed, **or** the documented in-process fallback). That quote-prefix dual-host Sent prove is a separate bar from the 2.0.7 verify contract.
480
484
 
481
485
  Emails are addressed by their RFC822 **Message-ID**. Pass `message_id`, or pass the `file_path` from `mail_search` / `mail_recent` and the server reads the Message-ID out of the `.emlx` headers for you. `mail_archive` moves the message to its account's Archive (or All Mail) mailbox; `mail_trash` moves it to that account's Trash.
482
486
 
@@ -597,9 +601,9 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
597
601
 
598
602
  The message names which process macOS was actually asking about. Work through it in this order:
599
603
 
600
- 1. **MacBook / Terminal (`permissions` CLI):** there is **no** always-on indexer. Re-run `apple-tools-mcp permissions` from **Terminal.app**, Allow the **printed `process.execPath`**, and check System Settings → Privacy & Security → **Automation** for that node → Contacts, Calendar, Mail, and Messages. Do **not** start `apple-tools-indexer`.
604
+ 1. **MacBook / Terminal (`permissions` CLI):** there is **no** always-on indexer. Re-run `apple-tools-mcp permissions` from **Terminal.app**, Allow the **printed `process.execPath`**, and check System Settings → Privacy & Security → **Automation** for that node → Contacts, Calendar, Mail, Messages, and System Events. Do **not** start `apple-tools-indexer`.
601
605
  2. **Mini only:** `pgrep -fl apple-tools-indexer` / `~/.apple-tools-mcp/writer.sock`. If the LaunchAgent is the product host, start it so writes run under launchd-owned node (see [Mini ship-gate](#mini-ship-gate-host-setup)).
602
- 3. **Did you approve the Automation prompts for `node`?** Re-run `apple-tools-mcp permissions` with the product `node` (it prints `process.execPath`). Open System Settings → Privacy & Security → **Automation** and confirm **that printed node** is allowed to control **Mail**, **Messages**, Contacts, and Calendar. On Mini the LaunchAgent binary is whatever the plist / `which node` reports (`/Users/petercoates/.local/node/bin/node` is a Mini *example* only). MacBook Claude nvm is `/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node`, not Homebrew. 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.
606
+ 3. **Did you approve the Automation prompts for `node`?** Re-run `apple-tools-mcp permissions` with the product `node` (it prints `process.execPath`). Open System Settings → Privacy & Security → **Automation** and confirm **that printed node** is allowed to control **Mail**, **Messages**, Contacts, Calendar, and **System Events**. On Mini the LaunchAgent binary is whatever the plist / `which node` reports (`/Users/petercoates/.local/node/bin/node` is a Mini *example* only). MacBook Claude nvm is `/Users/petercoates/.nvm/versions/node/v22.21.1/bin/node`, not Homebrew. 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.
603
607
  4. **Mini: 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.
604
608
  5. **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. On Mini, start the daemon so the write goes through the bridge. On MacBook, use Terminal.app + the printed node.
605
609
  6. **"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". On MacBook / Terminal, Allow the printed `process.execPath`. On Mini, the write bridge / LaunchAgent may apply. A Contacts `-600` / “application isn't running” is a **cold launch**, not Automation — keep Contacts, Mail, and Messages running; Calendar does not need to stay open.
@@ -170,6 +170,7 @@ export function needsHostTccAdvice(message) {
170
170
  const text = String(message || "");
171
171
  if (!text) return false;
172
172
  if (text.includes(MAIL_SEND_TIMEOUT_GUIDANCE)) return false;
173
+ if (text.includes(MAIL_GUI_SCRIPTING_GUIDANCE)) return false;
173
174
  if (text.includes(CONTACTS_APP_NOT_RUNNING_GUIDANCE)) return false;
174
175
  if (text.includes(MAIL_APP_NOT_RUNNING_GUIDANCE)) return false;
175
176
  if (text.includes(MESSAGES_APP_NOT_RUNNING_GUIDANCE)) return false;
@@ -339,10 +340,22 @@ export const MAIL_TCC_GUIDANCE =
339
340
  */
340
341
  export const MAIL_ACCESSIBILITY_GUIDANCE =
341
342
  "macOS denied Accessibility (System Events could not drive Mail's compose window). " +
342
- "mail_send / mail_draft paste the body so Mail does not cite-wrap it (FB11734014); Mail's mailto command does not return an outgoing message on current Mini/MacBook Mail (-2753). " +
343
+ "mail_send / mail_draft type or paste the body so Mail does not cite-wrap it (FB11734014); Mail's mailto command does not return an outgoing message on current Mini/MacBook Mail (-2753). " +
343
344
  "Allow node in System Settings → Privacy & Security → Accessibility, and keep Automation → Mail allowed. " +
344
345
  "This is not a Mail Automation deny (-1743 / -10004).";
345
346
 
347
+ /**
348
+ * System Events Apple Events / GUI scripting is missing, so the keystroke
349
+ * compose path cannot run. Distinct from a Mail send hang: nothing was typed
350
+ * or sent. Mini write-bridge should return unsupported so stdio falls back
351
+ * in-process rather than wedging ~60s.
352
+ */
353
+ export const MAIL_GUI_SCRIPTING_GUIDANCE =
354
+ "System Events GUI scripting is unavailable for this process, so the body cannot be typed into Mail. " +
355
+ "mail_send / mail_draft use System Events keystrokes (Tab into the body, typed Returns) so Mail does not cite-wrap the Sent body (FB11734014). " +
356
+ "Allow node → System Events in System Settings → Privacy & Security → Automation (run apple-tools-mcp permissions after install/upgrade) in addition to node → Mail, and keep Accessibility allowed. " +
357
+ "This is not a Mail send timeout and not a Mail Automation deny (-1743 / -10004).";
358
+
346
359
  /**
347
360
  * AppleScript hung (ETIMEDOUT / -1712) while finding, opening, replying,
348
361
  * or sending. That is not -1743 / -10004, including before send.
@@ -385,7 +398,7 @@ export function tccGuidanceFor(source) {
385
398
  export function runAppleScript(script, options = {}) {
386
399
  const { timeout = DEFAULT_SCRIPT_TIMEOUT_MS, appName = null, language = null } = options;
387
400
  try {
388
- const output = safeOsascript(script, { timeout, language });
401
+ const output = safeOsascript(script, { timeout, language, killSignal: "SIGKILL" });
389
402
  return { ok: true, output: (output || "").trim(), error: null, kind: null };
390
403
  } catch (e) {
391
404
  const message = e && e.message ? e.message : String(e);
package/lib/mailWrite.js CHANGED
@@ -25,10 +25,12 @@ import {
25
25
  MAIL_TCC_GUIDANCE,
26
26
  MAIL_SEND_TIMEOUT_GUIDANCE,
27
27
  MAIL_ACCESSIBILITY_GUIDANCE,
28
+ MAIL_GUI_SCRIPTING_GUIDANCE,
28
29
  MAIL_APP_NOT_RUNNING_GUIDANCE,
29
30
  ATTRIBUTION_GUIDANCE,
30
31
  isHardTccDenial
31
32
  } from "./appleScript.js";
33
+ import { isIndexerMode } from "./processMode.js";
32
34
  import {
33
35
  planWrite,
34
36
  plannedWriteResult,
@@ -156,8 +158,21 @@ export function probeMailAutomation() {
156
158
  export const ACCESSIBILITY_DENIED_SENTINEL = "ACCESSIBILITY_DENIED";
157
159
  export const BODY_PASTE_MISDIRECTED_SENTINEL = "BODY_PASTE_MISDIRECTED";
158
160
  export const BODY_FOCUS_FAILED_SENTINEL = "BODY_FOCUS_FAILED";
159
- /** Compose body AX height vs To/Subject/Cc fields (those are ~22px). */
161
+ export const GUI_SCRIPTING_UNAVAILABLE_SENTINEL = "GUI_SCRIPTING_UNAVAILABLE";
162
+ export const SYSTEM_EVENTS_PROBE_MARKER = "ATM_SYSTEM_EVENTS_PROBE";
163
+ /** Compose body AX height vs To/Subject/Cc fields (those are ~22px). Used to reject short header AXTextAreas after Tab; does not walk windows. */
160
164
  export const MAIL_BODY_MIN_AX_HEIGHT = 50;
165
+ /** Mini-proven Tab count from default compose focus into the body. */
166
+ export const MAIL_BODY_TAB_MAX = 6;
167
+ /** Cheap System Events Automation probe used before compose; SIGKILL if it still hangs. */
168
+ export const MAIL_SE_PROBE_TIMEOUT_MS = 4000;
169
+ /** First-run / upgrade permissions probe: long enough for the Allow click. */
170
+ export const SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS = 30000;
171
+ /** Compose osascript budget (make + keystroke + send). Far below the ~60s SE wedge. */
172
+ export const MAIL_COMPOSE_TIMEOUT_MS = 25000;
173
+ export const MAIL_SE_APPLEEVENT_TIMEOUT_SEC = 8;
174
+ export const MAIL_APPLEEVENT_TIMEOUT_SEC = 15;
175
+ export const MAIL_SE_PROBE_APPLEEVENT_TIMEOUT_SEC = 3;
161
176
 
162
177
  /**
163
178
  * AppleScript ordinal/keyword tokens that fail compile in `set {a, b}`
@@ -206,10 +221,10 @@ end atmFocusedElement`;
206
221
  }
207
222
 
208
223
  /**
209
- * Body text for native Mail compose (clipboard paste, not AppleScript content).
224
+ * Body text for native Mail compose (keystroke / paste, not AppleScript content).
210
225
  *
211
226
  * HTML is tag-stripped so a plain-looking HTML body (`<p>…</p>`) becomes
212
- * typed/pasted text. Never prefixes lines with `>` and never emits
227
+ * typed text. Never prefixes lines with `>` and never emits
213
228
  * `<blockquote>` — AppleScript `content` / `html content` cite-wrap
214
229
  * (FB11734014). Mail's `mailto` command is not used: on current Mini/MacBook
215
230
  * Mail it does not return an outgoing message (`newMessage` stays undefined,
@@ -231,36 +246,95 @@ export function composeBodyNeedle(body) {
231
246
  return needle.length > 120 ? needle.slice(0, 120) : needle;
232
247
  }
233
248
 
234
- const BODY_MIN_H = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
249
+ export function isSystemEventsProbeScript(script) {
250
+ return String(script || "").includes(SYSTEM_EVENTS_PROBE_MARKER);
251
+ }
252
+
253
+ export function buildSystemEventsProbeScript() {
254
+ const secs = asInteger(MAIL_SE_PROBE_APPLEEVENT_TIMEOUT_SEC, {
255
+ min: 1,
256
+ max: 15,
257
+ field: "seProbeTimeout"
258
+ });
259
+ return `-- ${SYSTEM_EVENTS_PROBE_MARKER}
260
+ with timeout of ${secs} seconds
261
+ tell application "System Events"
262
+ set atmName to name
263
+ if atmName is "" then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
264
+ set atmUi to UI elements enabled
265
+ end tell
266
+ end timeout
267
+ if atmUi is false then error "${ACCESSIBILITY_DENIED_SENTINEL}"
268
+ return "OK"`;
269
+ }
270
+
271
+ export function probeSystemEventsGui({ timeout = MAIL_SE_PROBE_TIMEOUT_MS } = {}) {
272
+ return runAppleScript(buildSystemEventsProbeScript(), {
273
+ timeout
274
+ });
275
+ }
235
276
 
236
277
  /**
237
- * AppleScript handlers: focus the compose body (not To/Subject) and paste.
238
- * System Events needs Accessibility; Mail make/send still needs Automation.
239
- *
240
- * macOS 26.x System Events has no `web area` class (compile -2741,
241
- * "Expected class name but found identifier") and no compound
242
- * `focused UI element` (compile -2741 / -2740). Hosts compile
243
- * `text area` / `scroll area` / `text field`, `UI element whose role is
244
- * "AXWebArea"`, `first UI element whose focused is true`, and
245
- * `value of attribute "AXFocusedUIElement"`. Never focus the first
246
- * AXTextArea blindly — on MacBook Mail that is a header field, and
247
- * Cmd-V becomes BODY_PASTE_MISDIRECTED.
248
- *
249
- * AppleScript treats ordinal suffixes as reserved tokens (`th` as in 4th,
250
- * also `st`/`nd`/`rd`). Dual-host Sent prove on 2.0.9 compiled past the
251
- * focused-UI fix, then failed in `atmFocusMailBody`:
252
- * `set {tw, th} to size of atmElem` → -2741 Expected expression but found
253
- * `th`. Size/position lists use `atmW`/`atmH` (hosts confirmed) and other
254
- * `atm*` names — never `th`/`tw`/`st`/`nd`/`rd`/`to`/`by`/`id` in
255
- * destructuring.
278
+ * First-run / upgrade probe: a live System Events Apple Event that pops
279
+ * Automation → System Events. Does not type into the focused UI.
280
+ * Timeout is long enough for the operator to click Allow.
281
+ * @returns {{ ok: boolean, message: string, kind: string|null }}
282
+ */
283
+ export function probeSystemEventsAutomation() {
284
+ const action = "system_events_automation_probe";
285
+ const summary = "ask System Events for its name (Automation / Apple Events check; nothing is typed)";
286
+ const result = probeSystemEventsGui({ timeout: SYSTEM_EVENTS_AUTOMATION_PROBE_TIMEOUT_MS });
287
+ if (!result.ok) {
288
+ const kind = systemEventsGrantKind(result);
289
+ const guidance = isMailAccessibilityDenial(result)
290
+ ? MAIL_ACCESSIBILITY_GUIDANCE
291
+ : MAIL_GUI_SCRIPTING_GUIDANCE;
292
+ return {
293
+ ok: false,
294
+ kind,
295
+ message: `${action} failed — attempted to ${summary}. ${guidance}`
296
+ };
297
+ }
298
+ return {
299
+ ok: true,
300
+ kind: null,
301
+ message: writeSuccessMessage(
302
+ action,
303
+ "System Events Automation allowed (process name read; nothing was typed)"
304
+ )
305
+ };
306
+ }
307
+
308
+ function systemEventsGrantKind(result) {
309
+ if (!result) return "unknown";
310
+ if (result.kind === "timeout" || result.kind === "tcc" || result.kind === "attribution") {
311
+ return result.kind;
312
+ }
313
+ if (isMailGuiScriptingUnavailable(result) || isMailAccessibilityDenial(result)) {
314
+ return "tcc";
315
+ }
316
+ return result.kind || "unknown";
317
+ }
318
+
319
+ /**
320
+ * AppleScript handlers: Tab into the compose body and type it (typed Returns
321
+ * preserve newlines). Does not walk System Events `windows of process Mail`
322
+ * (macOS 26 reports 0). Calibration / verify uses the focused AX role (and
323
+ * AX value when it is readable) — never live Mail `content of` the outgoing
324
+ * message. Dual-host Mail 16 / macOS 26: `set content` reads back via
325
+ * `content of`, but Tab + keystroke/paste into the UI body never appears in
326
+ * `content of` (`typedSeen=false`, bodyLen 0) even when typing still produces
327
+ * a plain Sent `.emlx`. Never `set content` / `html content` (FB11734014).
256
328
  *
257
- * Strategies, each fail-closed unless the caret left the header:
258
- * 1. Click a tall AXWebArea (HTML compose body)
259
- * 2. Focus a tall `text area` (plain compose body), never a short header
260
- * 3. Click Subject then Tab into the body (stable Mini/MacBook order)
261
- * 4. Click the lower two-thirds of the compose window
329
+ * Tab count is unchanged (MAIL_BODY_TAB_MAX, Mini-proven 6). Fail-closed if
330
+ * AX focus is not a body role after Tab. Cmd-V only if AX value is readable
331
+ * and the needle is still missing. Header checks stay. System Events Apple
332
+ * events are wrapped in `with timeout`.
262
333
  */
263
334
  export function buildMailBodyPasteHandler() {
335
+ const tabMax = asInteger(MAIL_BODY_TAB_MAX, { min: 1, max: 12, field: "tabMax" });
336
+ const seSecs = asInteger(MAIL_SE_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 30, field: "seTimeout" });
337
+ const bodyMinH = asInteger(MAIL_BODY_MIN_AX_HEIGHT, { min: 1, max: 500, field: "bodyMinHeight" });
264
338
  return `${buildAtmFocusedElementHandler()}
265
339
 
266
340
  on atmSafeToPaste()
@@ -280,7 +354,7 @@ on atmSafeToPaste()
280
354
  try
281
355
  set {atmW, atmH} to size of fe
282
356
  end try
283
- if r is "AXTextArea" and atmH < ${BODY_MIN_H} then return false
357
+ if r is "AXTextArea" and atmH > 0 and atmH < ${bodyMinH} then return false
284
358
  ignoring case
285
359
  set bits to ""
286
360
  try
@@ -296,148 +370,106 @@ on atmSafeToPaste()
296
370
  end ignoring
297
371
  if r is "AXWebArea" then return true
298
372
  if r is "AXTextArea" then return true
299
- if atmH >= ${BODY_MIN_H} then return true
373
+ if atmH >= ${bodyMinH} then return true
300
374
  end try
301
375
  return false
302
376
  end tell
303
377
  end tell
304
378
  end atmSafeToPaste
305
379
 
306
- on atmFocusMailBody(expectedSubject)
380
+ on atmAxFocusedValue()
381
+ tell application "System Events"
382
+ try
383
+ set fe to my atmFocusedElement()
384
+ if fe is missing value then return ""
385
+ return value of fe as string
386
+ end try
387
+ end tell
388
+ return ""
389
+ end atmAxFocusedValue
390
+
391
+ on atmSplitParagraphs(bodyText)
392
+ set atmSavedDelim to AppleScript's text item delimiters
393
+ set AppleScript's text item delimiters to return
394
+ set atmCrParts to text items of bodyText
395
+ set AppleScript's text item delimiters to linefeed
396
+ set atmNorm to atmCrParts as string
397
+ set atmLines to text items of atmNorm
398
+ set AppleScript's text item delimiters to atmSavedDelim
399
+ return atmLines
400
+ end atmSplitParagraphs
401
+
402
+ on atmSpacesForTabs(atmLine)
403
+ set atmSavedDelim to AppleScript's text item delimiters
404
+ set AppleScript's text item delimiters to tab
405
+ set atmTabParts to text items of atmLine
406
+ set AppleScript's text item delimiters to " "
407
+ set atmOut to atmTabParts as string
408
+ set AppleScript's text item delimiters to atmSavedDelim
409
+ return atmOut
410
+ end atmSpacesForTabs
411
+
412
+ on atmKeystrokeText(atmText)
413
+ set atmSafe to my atmSpacesForTabs(atmText)
414
+ set atmLen to length of atmSafe
415
+ if atmLen is 0 then return
416
+ set atmPos to 1
307
417
  tell application "System Events"
308
418
  tell process "Mail"
309
- set frontmost to true
310
- if (count of windows) < 1 then error "MAIL_COMPOSE_WINDOW_MISSING"
311
- set w to window 1
312
- if expectedSubject is not "" then
313
- repeat with i from 1 to (count of windows)
314
- try
315
- if (name of window i as string) is expectedSubject then
316
- set w to window i
317
- exit repeat
318
- end if
319
- end try
320
- end repeat
321
- end if
322
- try
323
- perform action "AXRaise" of w
324
- end try
325
- try
326
- click (first UI element of w whose role is "AXWebArea")
327
- delay 0.08
328
- if my atmSafeToPaste() then return
329
- end try
330
- try
331
- click (first UI element of scroll area 1 of w whose role is "AXWebArea")
332
- delay 0.08
333
- if my atmSafeToPaste() then return
334
- end try
335
- try
336
- click (first UI element of scroll area 1 of splitter group 1 of w whose role is "AXWebArea")
337
- delay 0.08
338
- if my atmSafeToPaste() then return
339
- end try
340
- try
341
- click (first UI element of scroll area 1 of group 1 of splitter group 1 of w whose role is "AXWebArea")
342
- delay 0.08
343
- if my atmSafeToPaste() then return
344
- end try
345
- set atmTallText to missing value
346
- set atmSubjectElem to missing value
347
- set atmQueue to UI elements of w
348
- set atmWalked to 0
349
- repeat while (count of atmQueue) > 0 and atmWalked < 120
350
- set atmWalked to atmWalked + 1
351
- set atmElem to item 1 of atmQueue
352
- if (count of atmQueue) is 1 then
353
- set atmQueue to {}
354
- else
355
- set atmQueue to items 2 thru -1 of atmQueue
356
- end if
357
- try
358
- if (role of atmElem as string) is "AXWebArea" then
359
- set {atmW, atmH} to size of atmElem
360
- if atmH >= ${BODY_MIN_H} then
361
- click atmElem
362
- delay 0.08
363
- if my atmSafeToPaste() then return
364
- end if
365
- end if
366
- end try
367
- try
368
- if (role of atmElem as string) is "AXTextArea" then
369
- set {atmW, atmH} to size of atmElem
370
- if atmH >= ${BODY_MIN_H} and atmTallText is missing value then set atmTallText to atmElem
371
- end if
372
- end try
373
- try
374
- set r to (role of atmElem as string)
375
- if r is "AXTextField" or r is "AXTextArea" then
376
- set bits to ""
377
- try
378
- set bits to bits & (description of atmElem as string)
379
- end try
380
- try
381
- set bits to bits & (name of atmElem as string)
382
- end try
383
- ignoring case
384
- if bits contains "Subject" and atmSubjectElem is missing value then set atmSubjectElem to atmElem
385
- end ignoring
386
- end if
387
- end try
388
- try
389
- set atmKids to UI elements of atmElem
390
- repeat with i from 1 to (count of atmKids)
391
- set end of atmQueue to item i of atmKids
392
- end repeat
393
- end try
419
+ repeat while atmPos is less than or equal to atmLen
420
+ set atmEndPos to atmPos + 31
421
+ if atmEndPos > atmLen then set atmEndPos to atmLen
422
+ keystroke (text atmPos thru atmEndPos of atmSafe)
423
+ set atmPos to atmEndPos + 1
394
424
  end repeat
395
- try
396
- set ta to text area 1 of scroll area 1 of w
397
- set {atmW, atmH} to size of ta
398
- if atmH >= ${BODY_MIN_H} then
399
- set focused of ta to true
400
- delay 0.08
401
- if my atmSafeToPaste() then return
402
- end if
403
- end try
404
- if atmTallText is not missing value then
405
- try
406
- set focused of atmTallText to true
407
- end try
408
- try
409
- click atmTallText
410
- end try
411
- delay 0.08
412
- if my atmSafeToPaste() then return
413
- end if
414
- try
415
- click (first text field of w whose description contains "Subject")
416
- delay 0.08
417
- key code 48
418
- delay 0.08
419
- if my atmSafeToPaste() then return
420
- end try
421
- if atmSubjectElem is not missing value then
422
- try
423
- click atmSubjectElem
424
- delay 0.08
425
- key code 48
426
- delay 0.08
427
- if my atmSafeToPaste() then return
428
- end try
429
- end if
430
- set {atmX, atmY} to position of w
431
- set {atmWinW, atmWinH} to size of w
432
- click at {(atmX + (atmWinW div 2)) as integer, (atmY + ((atmWinH * 2) div 3)) as integer}
433
- delay 0.08
434
- if my atmSafeToPaste() then return
435
- error "${BODY_FOCUS_FAILED_SENTINEL}"
436
425
  end tell
437
426
  end tell
438
- end atmFocusMailBody
427
+ end atmKeystrokeText
428
+
429
+ on atmTabIntoMailBody(msg)
430
+ tell application "Mail" to activate
431
+ delay 0.25
432
+ with timeout of ${seSecs} seconds
433
+ tell application "System Events"
434
+ tell process "Mail" to set frontmost to true
435
+ end tell
436
+ end timeout
437
+ delay 0.08
438
+ repeat with atmN from 0 to ${tabMax}
439
+ if my atmSafeToPaste() then return
440
+ if atmN is ${tabMax} then error "${BODY_FOCUS_FAILED_SENTINEL}"
441
+ with timeout of ${seSecs} seconds
442
+ tell application "System Events"
443
+ tell process "Mail"
444
+ key code 48
445
+ end tell
446
+ end tell
447
+ end timeout
448
+ delay 0.08
449
+ end repeat
450
+ error "${BODY_FOCUS_FAILED_SENTINEL}"
451
+ end atmTabIntoMailBody
452
+
453
+ on atmTypeMailBody(bodyText)
454
+ set atmLines to my atmSplitParagraphs(bodyText)
455
+ set atmLast to count of atmLines
456
+ if atmLast is 0 then return
457
+ with timeout of ${seSecs} seconds
458
+ repeat with atmI from 1 to atmLast
459
+ set atmLine to item atmI of atmLines as string
460
+ my atmKeystrokeText(atmLine)
461
+ if atmI < atmLast then
462
+ tell application "System Events"
463
+ tell process "Mail"
464
+ key code 36
465
+ end tell
466
+ end tell
467
+ end if
468
+ end repeat
469
+ end timeout
470
+ end atmTypeMailBody
439
471
 
440
- on atmPasteMailBody(bodyText, expectedSubject)
472
+ on atmPasteMailBodyFallback(bodyText)
441
473
  set savedClip to ""
442
474
  set hadClip to false
443
475
  try
@@ -445,26 +477,47 @@ on atmPasteMailBody(bodyText, expectedSubject)
445
477
  set hadClip to true
446
478
  end try
447
479
  set the clipboard to bodyText
448
- tell application "Mail" to activate
449
- delay 0.4
450
480
  try
451
- atmFocusMailBody(expectedSubject)
452
- if atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
453
- tell application "System Events"
454
- tell process "Mail"
455
- keystroke "v" using command down
481
+ with timeout of ${seSecs} seconds
482
+ tell application "System Events"
483
+ tell process "Mail"
484
+ keystroke "v" using command down
485
+ end tell
456
486
  end tell
457
- end tell
458
- delay 0.2
487
+ end timeout
488
+ delay 0.15
459
489
  on error errMsg number errNum
460
490
  if hadClip then set the clipboard to savedClip
461
491
  if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
462
492
  error errMsg number errNum
463
493
  end try
464
494
  if hadClip then set the clipboard to savedClip
465
- end atmPasteMailBody`;
495
+ end atmPasteMailBodyFallback
496
+
497
+ on atmFillMailBody(bodyText, needle, msg)
498
+ try
499
+ atmTabIntoMailBody(msg)
500
+ if my atmSafeToPaste() is false then error "${BODY_FOCUS_FAILED_SENTINEL}"
501
+ atmTypeMailBody(bodyText)
502
+ delay 0.12
503
+ if needle is not "" then
504
+ set atmAxVal to my atmAxFocusedValue()
505
+ if atmAxVal is not "" then
506
+ if atmAxVal does not contain needle then
507
+ atmPasteMailBodyFallback(bodyText)
508
+ end if
509
+ end if
510
+ end if
511
+ on error errMsg number errNum
512
+ if errMsg contains "assistive access" or errNum is -25211 then error "${ACCESSIBILITY_DENIED_SENTINEL}"
513
+ if errMsg contains "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}" then error "${GUI_SCRIPTING_UNAVAILABLE_SENTINEL}"
514
+ error errMsg number errNum
515
+ end try
516
+ end atmFillMailBody`;
466
517
  }
467
518
 
519
+ export const buildMailBodyFillHandler = buildMailBodyPasteHandler;
520
+
468
521
  function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
469
522
  const toCount = asInteger(to.length, { min: 1, max: 99, field: "toCount" });
470
523
  const ccCount = asInteger(cc.length, { min: 0, max: 99, field: "ccCount" });
@@ -482,31 +535,10 @@ function composeMisdirectChecks({ to, cc = [], bcc = [], subject, body }) {
482
535
  })
483
536
  .join("\n");
484
537
  const bodyCheck = needle
485
- ? ` set atmGotBody to ""
486
- try
487
- set atmGotBody to content of newMessage as string
488
- end try
489
- if atmGotBody is "" then
490
- try
491
- save newMessage
492
- end try
493
- delay 0.2
494
- try
495
- set atmGotBody to content of newMessage as string
496
- end try
497
- end if
498
- if atmGotBody does not contain ${asString(needle)} then
499
- try
500
- set atmFe to my atmFocusedElement()
501
- if atmFe is not missing value then
502
- tell application "System Events"
503
- set atmAxVal to value of atmFe as string
504
- if atmAxVal contains ${asString(needle)} then set atmGotBody to atmAxVal
505
- end tell
506
- end if
507
- end try
508
- end if
509
- if atmGotBody does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"`
538
+ ? ` set atmAxVal to my atmAxFocusedValue()
539
+ if atmAxVal is not "" then
540
+ if atmAxVal does not contain ${asString(needle)} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
541
+ end if`
510
542
  : "";
511
543
  return ` if (count of to recipients of newMessage) is not ${toCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
512
544
  if (count of cc recipients of newMessage) is not ${ccCount} then error "${BODY_PASTE_MISDIRECTED_SENTINEL}"
@@ -526,9 +558,13 @@ ${bodyCheck}`;
526
558
  * Do not set AppleScript `content` or `html content`: Ventura+ FB11734014
527
559
  * cite-wraps those setters (`>` prefixes + `<blockquote type="cite">`).
528
560
  * Recipients and subject are AppleScript properties (never paste). The body
529
- * is pasted into the compose window (native editor) via System Events only
530
- * after caret focus leaves the header. If paste lands in To/Subject, or the
531
- * body never receives the intended text, abort + delete (nothing sent).
561
+ * is typed into the native compose editor via System Events (Tab into body
562
+ * until AX focused role is a body field, typed Returns for newlines, Cmd-V
563
+ * only if AX value is readable and the needle is still missing). Do not walk
564
+ * `windows of process Mail`. Do not use Mail `content of` as the oracle that
565
+ * typed/pasted text landed (Mail 16 / macOS 26: that property stays empty).
566
+ * If AX body focus cannot be established, or headers change, abort + delete
567
+ * (nothing sent). Ship prove that the body landed is Sent `.emlx` after send.
532
568
  */
533
569
  export function buildComposeScript({ to, cc, bcc, subject, body, send, html = false }) {
534
570
  const recipients = [
@@ -537,9 +573,11 @@ export function buildComposeScript({ to, cc, bcc, subject, body, send, html = fa
537
573
  recipientLines(bcc, "bcc recipient")
538
574
  ].filter((block) => block.length > 0).join("\n");
539
575
 
576
+ const mailSecs = asInteger(MAIL_APPLEEVENT_TIMEOUT_SEC, { min: 1, max: 60, field: "mailTimeout" });
540
577
  const plainBody = composeNativeBody(body, { html });
578
+ const needle = composeBodyNeedle(plainBody);
541
579
  const pasteAndVerify = plainBody
542
- ? ` atmPasteMailBody(${asString(plainBody)}, ${asString(subject)})
580
+ ? ` atmFillMailBody(${asString(plainBody)}, ${asString(needle)}, newMessage)
543
581
  tell application "Mail"
544
582
  ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
545
583
  end tell`
@@ -548,11 +586,13 @@ ${composeMisdirectChecks({ to, cc, bcc, subject, body: plainBody })}
548
586
  return `${buildMailBodyPasteHandler()}
549
587
 
550
588
  tell application "Mail"
551
- set newMessage to make new outgoing message with properties {subject:${asString(subject)}, visible:true}
552
- tell newMessage
589
+ with timeout of ${mailSecs} seconds
590
+ set newMessage to make new outgoing message with properties {subject:${asString(subject)}, visible:true}
591
+ tell newMessage
553
592
  ${recipients}
554
- end tell
555
- activate
593
+ end tell
594
+ activate
595
+ end timeout
556
596
  end tell
557
597
  try
558
598
  ${pasteAndVerify}
@@ -564,10 +604,12 @@ on error errMsg number errNum
564
604
  end try
565
605
  set atmOutgoingId to ""
566
606
  tell application "Mail"
567
- try
568
- set atmOutgoingId to message id of newMessage as string
569
- end try
570
- ${send ? "send newMessage" : "save newMessage"}
607
+ with timeout of ${mailSecs} seconds
608
+ try
609
+ set atmOutgoingId to message id of newMessage as string
610
+ end try
611
+ ${send ? "send newMessage" : "save newMessage"}
612
+ end timeout
571
613
  end tell
572
614
  if atmOutgoingId is not "" then return atmOutgoingId
573
615
  return "OK"`;
@@ -927,6 +969,14 @@ export function verifyQueuedMessage(match = {}, { attempts = SENT_VERIFY_ATTEMPT
927
969
  return last;
928
970
  }
929
971
 
972
+ export function isMailGuiScriptingUnavailable(result) {
973
+ if (!result) return false;
974
+ const text = String(result.error || "").toLowerCase();
975
+ if (text.includes("gui_scripting_unavailable")) return true;
976
+ if (result.kind === "timeout" && text.includes("system events")) return true;
977
+ return false;
978
+ }
979
+
930
980
  export function isMailAccessibilityDenial(result) {
931
981
  if (!result) return false;
932
982
  const text = String(result.error || "").toLowerCase();
@@ -946,6 +996,9 @@ export function isMailBodyPasteMisdirected(result) {
946
996
 
947
997
  function failure(action, summary, result, secrets) {
948
998
  const err = String((result && result.error) || "").toLowerCase();
999
+ if (err.includes("gui_scripting_unavailable")) {
1000
+ return `${action} failed — attempted to ${summary}. ${MAIL_GUI_SCRIPTING_GUIDANCE}`;
1001
+ }
949
1002
  if (err.includes("body_focus_failed")) {
950
1003
  return `${action} failed — attempted to ${summary}. The compose caret could not be moved into the message body; nothing was sent.`;
951
1004
  }
@@ -1051,10 +1104,24 @@ function finalizeMailWrite({
1051
1104
  return { ok: false, delivered: false, mailbox: null, message: failure(action, summary, result, secrets) };
1052
1105
  }
1053
1106
 
1107
+ function guiScriptingUnavailableResult(action, summary, result, { indexerMode } = {}) {
1108
+ const unsupported = Boolean(indexerMode);
1109
+ const message = isMailAccessibilityDenial(result)
1110
+ ? `${action} failed — attempted to ${summary}. ${MAIL_ACCESSIBILITY_GUIDANCE}`
1111
+ : `${action} failed — attempted to ${summary}. ${MAIL_GUI_SCRIPTING_GUIDANCE}`;
1112
+ return {
1113
+ ok: false,
1114
+ delivered: false,
1115
+ mailbox: null,
1116
+ ...(unsupported ? { unsupported: true } : {}),
1117
+ message
1118
+ };
1119
+ }
1120
+
1054
1121
  /**
1055
1122
  * Compose and send (or save as draft) a new email.
1056
1123
  */
1057
- export function mailCompose(args = {}, { draft = false } = {}) {
1124
+ export function mailCompose(args = {}, { draft = false, indexerMode = isIndexerMode() } = {}) {
1058
1125
  const action = draft ? "mail_draft" : "mail_send";
1059
1126
 
1060
1127
  const to = validateEmailList(args.to, "to");
@@ -1093,6 +1160,14 @@ export function mailCompose(args = {}, { draft = false } = {}) {
1093
1160
  });
1094
1161
  if (!plan.proceed) return plannedWriteResult(plan);
1095
1162
 
1163
+ const plainBody = composeNativeBody(body.text, { html: bodyFormat === "html" });
1164
+ if (plainBody) {
1165
+ const se = probeSystemEventsGui();
1166
+ if (!se.ok) {
1167
+ return guiScriptingUnavailableResult(action, summary, se, { indexerMode });
1168
+ }
1169
+ }
1170
+
1096
1171
  const script = buildComposeScript({
1097
1172
  to: to.addresses,
1098
1173
  cc: cc.addresses,
@@ -1103,7 +1178,14 @@ export function mailCompose(args = {}, { draft = false } = {}) {
1103
1178
  html: bodyFormat === "html"
1104
1179
  });
1105
1180
 
1106
- const result = runAppleScript(script, { timeout: 60000, appName: "Mail" });
1181
+ const result = runAppleScript(script, { timeout: MAIL_COMPOSE_TIMEOUT_MS, appName: "Mail" });
1182
+ if (
1183
+ !result.ok &&
1184
+ indexerMode &&
1185
+ (isMailGuiScriptingUnavailable(result) || isMailAccessibilityDenial(result))
1186
+ ) {
1187
+ return guiScriptingUnavailableResult(action, summary, result, { indexerMode });
1188
+ }
1107
1189
  return finalizeMailWrite({
1108
1190
  action,
1109
1191
  summary,
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * First-run / upgrade permissions command.
3
3
  *
4
- * Probes Contacts, Calendar, Mail, and Messages under process.execPath so
5
- * macOS can pop Allow dialogs for that node binary. The user clicks Allow;
6
- * this command cannot grant silently.
4
+ * Probes Contacts, Calendar, Mail, Messages, and System Events under
5
+ * process.execPath so macOS can pop Allow dialogs for that node binary.
6
+ * The user clicks Allow; this command cannot grant silently.
7
7
  *
8
8
  * Always runs in this process (never via the write bridge): the point is to
9
9
  * attach the dialogs to process.execPath. If the invoked CLI / shebang
@@ -15,13 +15,13 @@
15
15
  import fs from "fs";
16
16
  import path from "path";
17
17
  import { classifyAppleScriptError } from "./appleScript.js";
18
- import { probeMailAutomation } from "./mailWrite.js";
18
+ import { probeMailAutomation, probeSystemEventsAutomation } from "./mailWrite.js";
19
19
  import { probeMessagesAutomation } from "./messagesWrite.js";
20
20
  import { probeContactsAutomation } from "./contactsWrite.js";
21
21
  import { probeCalendarAutomation } from "./calendarWrite.js";
22
22
  import { detectLaunchAgentContext, hostAutomationAdvice } from "./writeRouting.js";
23
23
 
24
- export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages"];
24
+ export const REQUIRED_SURFACES = ["Contacts", "Calendar", "Mail", "Messages", "System Events"];
25
25
 
26
26
  /** Mini ship-gate host example — not a universal path. */
27
27
  export const EXAMPLE_MINI_NODE = "/Users/petercoates/.local/node/bin/node";
@@ -172,6 +172,9 @@ export function classifyGrantStatus(result) {
172
172
  if (kind === "app_not_running") {
173
173
  return GRANT_ERROR;
174
174
  }
175
+ if (/accessibility_denied|assistive access|gui_scripting_unavailable/i.test(text)) {
176
+ return GRANT_MISSING;
177
+ }
175
178
  if (/tcc|automation deny|not authorized|not permitted|not allowed|timed out|etimedout|responsible-process|attribution/i.test(text)) {
176
179
  return GRANT_MISSING;
177
180
  }
@@ -198,7 +201,7 @@ export function exitCodeForGrants(grants) {
198
201
 
199
202
  /**
200
203
  * Advisory Full Disk Access touch. Read tools need FDA on this node; it is
201
- * not one of the four required Automation grants and never fails the command.
204
+ * not one of the required Automation grants and never fails the command.
202
205
  *
203
206
  * @returns {{ status: "readable"|"missing"|"skipped", message: string }}
204
207
  */
@@ -247,7 +250,8 @@ export function defaultPermissionsProbes() {
247
250
  Contacts: probeContactsAutomation,
248
251
  Calendar: probeCalendarAutomation,
249
252
  Mail: probeMailAutomation,
250
- Messages: probeMessagesAutomation
253
+ Messages: probeMessagesAutomation,
254
+ "System Events": probeSystemEventsAutomation
251
255
  };
252
256
  }
253
257
 
@@ -267,12 +271,13 @@ function bannerLines(binary, version) {
267
271
  "dry_run of mail_send / messages_send does not count: this command uses real Apple Events.",
268
272
  "Nothing is sent to third parties. Mail composes and discards a temporary outgoing message.",
269
273
  "Messages only enumerates accounts. Contacts creates and deletes a throwaway person in-script.",
270
- "Calendar lists calendars only — no events are created."
274
+ "Calendar lists calendars only — no events are created.",
275
+ "System Events is asked for its process name (mail_send keystrokes; nothing is typed)."
271
276
  ];
272
277
  }
273
278
 
274
279
  /**
275
- * Run the four live probes and print a grant report.
280
+ * Run the live Automation probes and print a grant report.
276
281
  *
277
282
  * @param {object} [options]
278
283
  * @param {string} [options.execPath]
@@ -349,7 +354,7 @@ export async function runPermissionsCommand({
349
354
 
350
355
  const code = exitCodeForGrants(grants);
351
356
  if (code === 0) {
352
- log("Result: PASS — Contacts, Calendar, Mail, and Messages are granted for this node.");
357
+ log("Result: PASS — Contacts, Calendar, Mail, Messages, and System Events are granted for this node.");
353
358
  log("Safe to re-run; already-granted surfaces report OK without another click.");
354
359
  } else {
355
360
  log("Result: INCOMPLETE — one or more required grants are missing or errored.");
package/lib/shell.js CHANGED
@@ -131,9 +131,12 @@ export function safeOsascript(script, options = {}) {
131
131
  args.push("-e", script);
132
132
 
133
133
  // Use -e with the script as an argument (never a shell string).
134
+ // Hung Apple Events ignore SIGTERM (Mini write-bridge ~60s wedge).
135
+ // Timeout must SIGKILL osascript so compose fails fast instead of hanging.
134
136
  const result = spawnSync('osascript', args, {
135
137
  encoding: 'utf-8',
136
138
  timeout,
139
+ killSignal: 'SIGKILL',
137
140
  shell: false
138
141
  });
139
142
 
@@ -91,7 +91,7 @@ export function terminalAutomationAdvice(execPath = process.execPath) {
91
91
  return (
92
92
  `Run this from Terminal.app (short-lived CLI / MacBook host — no always-on indexer). ` +
93
93
  `Click Allow for the printed process.execPath${printed}. ` +
94
- `Check System Settings → Privacy & Security → Automation for that node → Contacts, Calendar, Mail, and Messages. ` +
94
+ `Check System Settings → Privacy & Security → Automation for that node → Contacts, Calendar, Mail, Messages, and System Events. ` +
95
95
  `Do not start apple-tools-indexer.`
96
96
  );
97
97
  }
@@ -102,7 +102,7 @@ export function terminalAutomationAdvice(execPath = process.execPath) {
102
102
  export function miniLaunchAgentAdvice() {
103
103
  return (
104
104
  "This Mac has a Mini write-bridge / LaunchAgent. Grant the daemon's node Full Disk Access (reads) and " +
105
- "Allow that node in System Settings → Privacy & Security → Automation for Mail.app, Messages.app, Contacts.app, and Calendar.app. " +
105
+ "Allow that node in System Settings → Privacy & Security → Automation for Mail.app, Messages.app, Contacts.app, Calendar.app, and System Events (mail_send / mail_draft type the body via keystrokes). " +
106
106
  "Do not add node via + in the Contacts or Calendars privacy lists."
107
107
  );
108
108
  }
@@ -127,7 +127,7 @@ export function tccFallbackAdvice({
127
127
  if (mini && bridgeAvailable) {
128
128
  return (
129
129
  "The indexer daemon was reachable but the write was still denied; grant the daemon's node binary Full Disk Access (reads) and " +
130
- "Allow node in System Settings > Privacy & Security > Automation for Mail.app, Messages.app, Contacts.app, and Calendar.app. " +
130
+ "Allow node in System Settings > Privacy & Security > Automation for Mail.app, Messages.app, Contacts.app, Calendar.app, and System Events. " +
131
131
  "A hang or timeout on Mail compose is TCC / Automation denied, not Mail.app missing. " +
132
132
  "Do not add node via + in the Contacts or Calendars privacy lists — those panes often have no Add button."
133
133
  );
package/lib/writeTools.js CHANGED
@@ -57,7 +57,7 @@ export const WRITE_TOOL_DEFINITIONS = [
57
57
  bcc: { type: "array", items: { type: "string" }, description: "BCC email addresses" },
58
58
  subject: { type: "string", description: "Subject line (required)" },
59
59
  body: { type: "string", description: "Message body (required)" },
60
- body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped and pasted into Mail's compose window; AppleScript content/html content is not set because it quote-wraps the Sent body" },
60
+ body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped; the body is typed into Mail's compose window (System Events keystrokes). AppleScript content/html content is not set because it quote-wraps the Sent body" },
61
61
  ...CONFIRM_PROPS
62
62
  },
63
63
  required: ["to", "subject", "body"]
@@ -74,7 +74,7 @@ export const WRITE_TOOL_DEFINITIONS = [
74
74
  bcc: { type: "array", items: { type: "string" }, description: "BCC email addresses" },
75
75
  subject: { type: "string", description: "Subject line" },
76
76
  body: { type: "string", description: "Message body" },
77
- body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped and pasted into Mail's compose window; AppleScript content/html content is not set because it quote-wraps the Sent body" },
77
+ body_format: { type: "string", enum: ["plain", "html"], description: "Plain text (default) or HTML. HTML is tag-stripped; the body is typed into Mail's compose window (System Events keystrokes). AppleScript content/html content is not set because it quote-wraps the Sent body" },
78
78
  ...CONFIRM_PROPS
79
79
  },
80
80
  required: ["to"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-tools-mcp",
3
- "version": "2.0.10",
3
+ "version": "2.1.1",
4
4
  "description": "MCP server for semantic search and write actions across Apple Mail, Messages, Calendar, and Contacts",
5
5
  "type": "module",
6
6
  "main": "index.js",