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 { 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<void> {
|
||||
|
|
@ -415,6 +431,11 @@ async function dispatch(name: string, args: unknown): Promise<string> {
|
|||
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}`);
|
||||
}
|
||||
|
|
|
|||
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