import { execFile } from 'node:child_process'; import type { Store } from '../store/index.js'; import type { Event } from '../model/types.js'; export interface NotifyLogger { info(msg: string): void; error(msg: string): void; } /** osascript 执行函数(测试注入假实现,不真发通知) */ export type OsascriptFn = (script: string) => void; /** 同一任务同类型通知的抑制窗口 */ export const NOTIFY_SUPPRESS_MS = 60_000; const MAX_LEN = 80; /** AppleScript 字符串注入防护:去换行、转义反斜杠与双引号,截断 80 字符 */ export function sanitizeForOsascript(s: string): string { const oneLine = String(s).replace(/[\r\n]+/g, ' ').trim(); const cut = oneLine.length > MAX_LEN ? `${oneLine.slice(0, MAX_LEN - 1)}…` : oneLine; return cut.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } const GATE_NAME: Record = { plan_review: '拆解', spec_review: '方案', exec_review: 'PR' }; interface NotifierOptions { osascript?: OsascriptFn; now?: () => number; } /** * 事件 → 通知文案(不触发的事件返回 null)。kind 用于同任务同类型 60s 抑制。 * - status.changed → plan/spec/exec_review:「⏳ <任务名> 等待审核(拆解/方案/PR)」; * exec_review 且复审任一 verdict=reject:「⚠ <任务名> 复审建议拒绝(PR 待审核)」 * - status.changed → needs_attention:「❌ <任务名> 连续失败需人工」 * - approval.granted 且 gate=exec:「✅ <任务名> 已合并完成」 */ export function buildNotification(store: Store, e: Event): { kind: string; message: string } | null { if (!e.taskId) return null; if (e.type === 'status.changed') { const to = String(e.payload.to ?? ''); if (to === 'needs_attention') { const t = store.getTask(e.taskId); if (!t) return null; return { kind: 'attention', message: `❌ ${t.title} 连续失败需人工` }; } if (to === 'plan_review' || to === 'spec_review' || to === 'exec_review') { const t = store.getTask(e.taskId); if (!t) return null; if (to === 'exec_review' && (t.result?.verdict === 'reject' || t.result?.securityVerdict === 'reject')) { return { kind: 'gate', message: `⚠ ${t.title} 复审建议拒绝(PR 待审核)` }; } return { kind: 'gate', message: `⏳ ${t.title} 等待审核(${GATE_NAME[to]})` }; } return null; } if (e.type === 'approval.granted' && e.payload.gate === 'exec') { const t = store.getTask(e.taskId); if (!t) return null; return { kind: 'merged', message: `✅ ${t.title} 已合并完成` }; } return null; } /** * 创建通知器:订阅 store 事件流,符合条件时发 macOS 原生通知(osascript display notification)。 * 同一任务同类型 60s 内只发一次;osascript 失败只记日志。返回取消订阅函数。 */ export function createNotifier(store: Store, log: NotifyLogger, opts: NotifierOptions = {}): () => void { const now = opts.now ?? Date.now; const osa: OsascriptFn = opts.osascript ?? ((script) => { execFile('osascript', ['-e', script], (err) => { if (err) log.error(`macOS 通知发送失败:${err.message}`); }); }); const lastSent = new Map(); // `${taskId}:${kind}` → 最近发送时刻 return store.subscribe((e) => { try { const n = buildNotification(store, e); if (!n) return; const key = `${e.taskId}:${n.kind}`; const t = now(); const prev = lastSent.get(key); if (prev !== undefined && t - prev < NOTIFY_SUPPRESS_MS) return; lastSent.set(key, t); osa(`display notification "${sanitizeForOsascript(n.message)}" with title "maestro"`); } catch (err) { log.error(`通知处理失败:${(err as Error).message}`); } }); } /** * 接线入口(照 startSyncLoop 模式):MAESTRO_NOTIFY=0 关闭;非 macOS 平台不启用。 * 返回取消订阅函数(shutdown 时调用),未启用时返回 null。 */ export function startNotifier(store: Store, app: { log: NotifyLogger }): (() => void) | null { if (process.env.MAESTRO_NOTIFY === '0') { app.log.info('macOS 通知已关闭(MAESTRO_NOTIFY=0)'); return null; } if (process.platform !== 'darwin') { app.log.info('非 macOS 平台,原生通知不启用'); return null; } const stop = createNotifier(store, app.log); app.log.info('macOS 通知已启用:审核闸/需人工/合并完成(同任务同类型 60s 抑制,MAESTRO_NOTIFY=0 关闭)'); return stop; }