diff --git a/packages/cli/src/commands/mail-watch.ts b/packages/cli/src/commands/mail-watch.ts
index 3e10ae0..ca6e621 100644
--- a/packages/cli/src/commands/mail-watch.ts
+++ b/packages/cli/src/commands/mail-watch.ts
@@ -269,7 +269,11 @@ export function xmlEscape(s: string): string {
.replace(/'/g, "'");
}
-function buildPlist(agent: string, tpsBin: string, extraHookArgs: string[]): string {
+/**
+ * Build the launchd plist for an agent's mail-watch daemon.
+ * Exported for unit testing (asserts ProcessType=Background + valid XML).
+ */
+export function buildPlist(agent: string, tpsBin: string, extraHookArgs: string[]): string {
const label = plistLabel(agent);
const stdout = join(logDir(), `mail-watch-${agent}.log`);
const stderr = join(logDir(), `mail-watch-${agent}.error.log`);
@@ -309,8 +313,18 @@ ${progArgs}
+
+ ProcessType
+ Background
+
ThrottleInterval
- 5
+ 10
StandardOutPath
${xmlEscape(stdout)}
diff --git a/packages/cli/test/mail-watch.test.ts b/packages/cli/test/mail-watch.test.ts
index ffd7038..8bbdaa5 100644
--- a/packages/cli/test/mail-watch.test.ts
+++ b/packages/cli/test/mail-watch.test.ts
@@ -13,7 +13,9 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdirSync, rmSync, writeFileSync, renameSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
-import { validateAgentId, watchMail, xmlEscape } from "../src/commands/mail-watch.js";
+import { buildPlist, validateAgentId, watchMail, xmlEscape } from "../src/commands/mail-watch.js";
+import { execFileSync } from "node:child_process";
+import { platform } from "node:os";
// ---------------------------------------------------------------------------
// Helpers
@@ -288,6 +290,43 @@ describe("xmlEscape", () => {
});
});
+// ---------------------------------------------------------------------------
+// buildPlist — generated launchd plist shape (ops-bayh: idle-reap immunity)
+// ---------------------------------------------------------------------------
+
+describe("buildPlist", () => {
+ it("sets ProcessType=Background so macOS does not idle-reap the watcher", () => {
+ const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []);
+ expect(xml).toContain("ProcessType");
+ // The follows the on the next line — assert the pairing.
+ expect(xml).toMatch(/ProcessType<\/key>\s*Background<\/string>/);
+ });
+
+ it("sets a non-zero ThrottleInterval", () => {
+ const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []);
+ expect(xml).toMatch(/ThrottleInterval<\/key>\s*10<\/integer>/);
+ });
+
+ it("keeps KeepAlive(Crashed:true) so a real crash still restarts", () => {
+ const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []);
+ expect(xml).toMatch(/KeepAlive<\/key>\s*\s*Crashed<\/key>\s*/);
+ });
+
+ it("generates plist that passes plutil -lint (valid XML, macOS only)", () => {
+ if (platform() !== "darwin") return; // plutil is macOS-only
+ const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", ["arg with spaces & "]);
+ const tmpFile = join(tmpdir(), `buildplist-lint-${Date.now()}.plist`);
+ writeFileSync(tmpFile, xml);
+ try {
+ // Throws (non-zero exit) if the plist is malformed.
+ const out = execFileSync("plutil", ["-lint", tmpFile], { encoding: "utf-8" });
+ expect(out).toContain("OK");
+ } finally {
+ rmSync(tmpFile, { force: true });
+ }
+ });
+});
+
// ---------------------------------------------------------------------------
// Daemon install (macOS only — validates args without actually calling launchctl)
// ---------------------------------------------------------------------------