feat(@messenger): ✨ add service restart tool for stalled iMessage pipeline
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
d2df7331e3
commit
098b51ce83
2 changed files with 161 additions and 0 deletions
|
|
@ -26,6 +26,7 @@ import { getNewMessages } from './tools/get-new-messages';
|
||||||
import { listDisplayRules } from './tools/list-rules';
|
import { listDisplayRules } from './tools/list-rules';
|
||||||
import { addDisplayRule } from './tools/add-rule';
|
import { addDisplayRule } from './tools/add-rule';
|
||||||
import { removeDisplayRule } from './tools/remove-rule';
|
import { removeDisplayRule } from './tools/remove-rule';
|
||||||
|
import { restartService, RESTARTABLE_SERVICES, type ServiceName } from './tools/restart-service';
|
||||||
import { shutdown } from './db';
|
import { shutdown } from './db';
|
||||||
|
|
||||||
const TOOLS = [
|
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<void> {
|
async function main(): Promise<void> {
|
||||||
|
|
@ -415,6 +431,11 @@ async function dispatch(name: string, args: unknown): Promise<string> {
|
||||||
const params = args as { rule_id?: string };
|
const params = args as { rule_id?: string };
|
||||||
return removeDisplayRule({ rule_id: params.rule_id ?? '' });
|
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:
|
default:
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
140
imessage-mcp/src/tools/restart-service.ts
Normal file
140
imessage-mcp/src/tools/restart-service.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<ServiceName, 'all'>): Promise<string> {
|
||||||
|
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<string> {
|
||||||
|
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<ServiceName, 'all'>[] = [
|
||||||
|
'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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue