diff --git a/imessage-mcp/src/index.ts b/imessage-mcp/src/index.ts index f50980a..f544bcb 100644 --- a/imessage-mcp/src/index.ts +++ b/imessage-mcp/src/index.ts @@ -26,6 +26,7 @@ import { getNewMessages } from './tools/get-new-messages'; import { listDisplayRules } from './tools/list-rules'; import { addDisplayRule } from './tools/add-rule'; import { removeDisplayRule } from './tools/remove-rule'; +import { restartService, RESTARTABLE_SERVICES, type ServiceName } from './tools/restart-service'; import { shutdown } from './db'; const TOOLS = [ @@ -277,6 +278,21 @@ const TOOLS = [ }, }, }, + { + name: 'restart_service', + description: 'Restart a mac-sync messaging service to clear a stalled send pipeline, without a reboot. Services: mac-sync (the macOS sync agent), mac-sync-server, imagent (iMessage delivery daemon), messages (Messages.app), or all (bounces the whole pipeline in order).', + inputSchema: { + type: 'object', + properties: { + service: { + type: 'string', + enum: [...RESTARTABLE_SERVICES], + description: 'Which service to restart, or "all" to bounce the whole pipeline in order', + }, + }, + required: ['service'], + }, + }, ]; async function main(): Promise { @@ -415,6 +431,11 @@ async function dispatch(name: string, args: unknown): Promise { const params = args as { rule_id?: string }; return removeDisplayRule({ rule_id: params.rule_id ?? '' }); } + case 'restart_service': { + const params = args as { service?: string }; + if (!params.service) throw new Error('service is required'); + return restartService({ service: params.service as ServiceName }); + } default: throw new Error(`Unknown tool: ${name}`); } diff --git a/imessage-mcp/src/tools/restart-service.ts b/imessage-mcp/src/tools/restart-service.ts new file mode 100644 index 0000000..ff4949b --- /dev/null +++ b/imessage-mcp/src/tools/restart-service.ts @@ -0,0 +1,140 @@ +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +/** + * Services involved in the iMessage send pipeline that this tool can bounce. + * + * send_queue (Postgres) → mac-sync-server → mac-sync (MacSyncApp agent) + * → Messages.app → imagent → delivery + * + * When sends stall, restarting the relevant layer (or `all`) clears most + * stuck states without a full reboot. + */ +export const RESTARTABLE_SERVICES = [ + 'mac-sync', + 'mac-sync-server', + 'imagent', + 'messages', + 'all', +] as const; + +export type ServiceName = (typeof RESTARTABLE_SERVICES)[number]; + +interface RestartServiceParams { + service: ServiceName; +} + +const LAUNCHD_LABEL: Record<'mac-sync' | 'mac-sync-server', string> = { + 'mac-sync': 'com.lilith.mac-sync', + 'mac-sync-server': 'com.lilith.mac-sync-server', +}; + +const EXEC_OPTS = { timeout: 15_000 } as const; + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Restart a launchd-managed agent via `kickstart -k` and report its new PID. */ +async function restartLaunchdJob(key: 'mac-sync' | 'mac-sync-server'): Promise { + const label = LAUNCHD_LABEL[key]; + const uid = process.getuid?.() ?? 0; + try { + await execAsync(`launchctl kickstart -k gui/${uid}/${label}`, EXEC_OPTS); + } catch (err) { + return `${key}: kickstart failed — ${errMsg(err)}`; + } + await delay(2_000); + try { + const { stdout } = await execAsync(`launchctl list | grep ${label} || true`, EXEC_OPTS); + const pid = stdout.trim().split(/\s+/)[0]; + return pid && pid !== '-' + ? `${key}: restarted (pid ${pid})` + : `${key}: kickstart issued, not yet listed`; + } catch (err) { + return `${key}: kickstart issued, verify failed — ${errMsg(err)}`; + } +} + +/** Kill imagent (the iMessage delivery daemon); launchd respawns it. */ +async function restartImagent(): Promise { + try { + await execAsync('killall imagent', EXEC_OPTS); + } catch { + // killall exits non-zero when no process matched — imagent respawns anyway. + } + await delay(2_500); + try { + const { stdout } = await execAsync('pgrep -x imagent || true', EXEC_OPTS); + const pid = stdout.trim().split('\n')[0]; + return pid ? `imagent: restarted (pid ${pid})` : 'imagent: killed, awaiting respawn'; + } catch (err) { + return `imagent: killed, verify failed — ${errMsg(err)}`; + } +} + +/** Force-quit Messages.app and relaunch it. */ +async function restartMessages(): Promise { + try { + await execAsync('killall Messages', EXEC_OPTS); + } catch { + // Not running — fine, the open below starts it fresh. + } + await delay(2_000); + try { + await execAsync('open -a Messages', EXEC_OPTS); + return 'messages: relaunched'; + } catch (err) { + return `messages: relaunch failed — ${errMsg(err)}`; + } +} + +async function restartOne(service: Exclude): Promise { + switch (service) { + case 'mac-sync': + case 'mac-sync-server': + return restartLaunchdJob(service); + case 'imagent': + return restartImagent(); + case 'messages': + return restartMessages(); + } +} + +/** + * Restart one mac-sync messaging service, or `all` of them in pipeline order + * (imagent → messages → mac-sync-server → mac-sync). Use when sends stall. + */ +export async function restartService(params: RestartServiceParams): Promise { + const { service } = params; + + if (!RESTARTABLE_SERVICES.includes(service)) { + throw new Error( + `restart_service: unknown service "${service}". ` + + `Valid: ${RESTARTABLE_SERVICES.join(', ')}`, + ); + } + + if (service === 'all') { + const order: Exclude[] = [ + 'imagent', + 'messages', + 'mac-sync-server', + 'mac-sync', + ]; + const results: string[] = []; + for (const svc of order) { + results.push(await restartOne(svc)); + } + return ['Restarted all services:', ...results.map((r) => ` ${r}`)].join('\n'); + } + + const result = await restartOne(service); + return result; +}