import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn, execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const DATA_DIR = join(process.env.LOCALAPPDATA ?? join(process.cwd(), ".pi-attention"), "PiAttention"); const STATES_DIR = join(DATA_DIR, "states"); const PID_FILE = join(DATA_DIR, "tray.pid"); const CONFIG_FILE = join(DATA_DIR, "config.json"); const TRAY_SCRIPT = join(ROOT, "tray", "PiAttentionTray.ps1"); const LAUNCHER_SCRIPT = join(ROOT, "tray", "LaunchPiAttention.ps1"); const DEFAULT_TEXT = "Pi 有任务完成,请及时处理"; type Status = "working" | "waiting_attention" | "acknowledged"; type State = { sessionId: string; sessionFile?: string; cwd: string; sessionName?: string; status: Status; pid: number; startedAt: number; updatedAt: number; settledAt?: number; lastMessage?: string }; function isWindows() { return process.platform === "win32"; } function stateId(sessionFile: string | undefined, cwd: string) { return createHash("sha256").update(sessionFile ?? cwd).digest("hex").slice(0, 24); } function projectName(cwd: string) { return cwd.replaceAll("\\", "/").split("/").filter(Boolean).pop() ?? cwd; } async function atomicWrite(path: string, value: unknown) { await mkdir(dirname(path), { recursive: true }); const temp = `${path}.${process.pid}.tmp`; await writeFile(temp, JSON.stringify(value, null, 2), "utf8"); await rename(temp, path); } function startTrayBestEffort() { if (!isWindows() || !existsSync(TRAY_SCRIPT)) return; try { const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", LAUNCHER_SCRIPT, "-DataDir", DATA_DIR, "-ScriptPath", TRAY_SCRIPT], { stdio: "ignore", windowsHide: true }); child.on("error", (error) => console.error("[pi-attention] 托盘启动失败", error)); } catch (error) { console.error("[pi-attention] 无法启动托盘程序,继续运行 Pi", error); } } async function resetTrayBestEffort() { try { const pid = Number.parseInt((await readFile(PID_FILE, "utf8")).trim(), 10); if (Number.isInteger(pid) && pid > 0) { try { process.kill(pid); } catch (error: any) { if (error.code !== "ESRCH") throw error; } } } catch (error: any) { if (error.code !== "ENOENT" && error.code !== "ESRCH") console.error("[pi-attention] 托盘进程清理失败", error); } try { await unlink(PID_FILE); } catch (error: any) { if (error.code !== "ENOENT") console.error("[pi-attention] PID 文件清理失败", error); } await new Promise((resolve) => setTimeout(resolve, 500)); startTrayBestEffort(); } async function safeReadStates(): Promise { try { const files = (await readdir(STATES_DIR)).filter((file) => file.endsWith(".json")); const result: State[] = []; for (const file of files) { try { result.push(JSON.parse(await readFile(join(STATES_DIR, file), "utf8")) as State); } catch { /* 忽略正在替换或损坏的状态文件 */ } } return result; } catch { return []; } } const execFile = promisify(execFileCallback); async function checkWindowsInstall() { const shortcutPath = join(process.env.APPDATA ?? "", "Microsoft", "Windows", "Start Menu", "Programs", "Pi Attention.lnk"); if (!isWindows()) return { startup: false, shortcut: false, shortcutPath }; const escapedShortcutPath = shortcutPath.replaceAll("'", "''"); try { const script = `$run = Get-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name 'PiAttentionTray' -ErrorAction SilentlyContinue; [pscustomobject]@{ startup = ($null -ne $run); shortcut = Test-Path -LiteralPath '${escapedShortcutPath}' } | ConvertTo-Json -Compress`; const { stdout } = await execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true }); return { ...JSON.parse(stdout.trim()), shortcutPath } as { startup: boolean; shortcut: boolean; shortcutPath: string }; } catch (error) { console.error("[pi-attention] 安装检查失败", error); return { startup: false, shortcut: existsSync(shortcutPath), shortcutPath }; } } export default function (pi: ExtensionAPI) { let statePath = ""; let state: State | undefined; let heartbeat: ReturnType | undefined; async function save(status: Status, message?: string) { if (!state || !statePath) return; const previousStatus = state.status; state = { ...state, status, updatedAt: Date.now(), lastMessage: message ?? state.lastMessage, ...(status === "waiting_attention" && previousStatus !== "waiting_attention" ? { settledAt: Date.now() } : {}) }; try { await atomicWrite(statePath, state); } catch (error) { console.error("[pi-attention] 状态写入失败,继续运行 Pi", error); } } pi.on("session_start", async (_event, ctx) => { if (!isWindows()) return; const sessionFile = ctx.sessionManager.getSessionFile(); state = { sessionId: stateId(sessionFile, ctx.cwd), sessionFile, cwd: ctx.cwd, sessionName: pi.getSessionName(), status: "acknowledged", pid: process.pid, startedAt: Date.now(), updatedAt: Date.now() }; statePath = join(STATES_DIR, `${state.sessionId}.json`); await save("acknowledged"); startTrayBestEffort(); heartbeat = setInterval(() => { void save(state?.status ?? "acknowledged"); }, 10000); }); pi.on("agent_start", async (_event, ctx) => { if (isWindows()) await save("working"); }); pi.on("agent_settled", async (_event, ctx) => { if (!isWindows()) return; const wasWaiting = state?.status === "waiting_attention"; await save("waiting_attention", "Pi 已完成当前工作,等待你的下一步指令"); ctx.ui.setStatus("pi-attention", "等待处理"); ctx.ui.setTitle(`[待处理] ${pi.getSessionName() ?? projectName(ctx.cwd)}`); if (ctx.hasUI && !wasWaiting) ctx.ui.notify("Pi 已完成当前工作,等待你的下一步指令", "warning"); }); pi.on("input", async (_event, ctx) => { if (isWindows() && state?.status === "waiting_attention") await save("working"); }); pi.registerCommand("attention", { description: "查看所有 Pi 会话状态", handler: async (_args, ctx) => { const states = await safeReadStates(); if (!states.length) return ctx.ui.notify("当前没有已注册的 Pi 会话", "info"); const waiting = states.filter((item) => item.status === "waiting_attention"); const lines = states.map((item) => `${item.status === "waiting_attention" ? "[待处理]" : `[${item.status}]`} ${item.sessionName ?? projectName(item.cwd)}`); ctx.ui.notify(`待处理 ${waiting.length} 个,共 ${states.length} 个\n${lines.join("\n")}`, waiting.length ? "warning" : "info"); }}); pi.registerCommand("attention:ack", { description: "确认当前 Pi 会话", handler: async (_args, ctx) => { if (!isWindows()) return; await save("acknowledged"); ctx.ui.setStatus("pi-attention", "已确认"); ctx.ui.setTitle(`Pi - ${pi.getSessionName() ?? projectName(ctx.cwd)}`); ctx.ui.notify("当前会话提醒已确认", "info"); }}); pi.registerCommand("attention:reset", { description: "清理并重启 Pi Attention 托盘进程", handler: async (_args, ctx) => { if (!isWindows()) return; await resetTrayBestEffort(); ctx.ui.notify("Pi Attention 托盘进程已清理并重新启动", "info"); }}); pi.registerCommand("attention:check", { description: "检查 Pi Attention 的 Windows 自启动和快捷方式", handler: async (_args, ctx) => { const result = await checkWindowsInstall(); const startup = result.startup ? "已配置" : "未配置"; const shortcut = result.shortcut ? "已存在" : "不存在"; ctx.ui.notify(`Pi Attention 安装检查\nWindows 自启动:${startup}\n开始菜单快捷方式:${shortcut}\n路径:${result.shortcutPath}`, result.startup && result.shortcut ? "info" : "warning"); }}); pi.registerCommand("attention:setup", { description: "配置 Pi Attention 托盘提醒", handler: async (_args, ctx: ExtensionCommandContext) => { if (!isWindows()) return ctx.ui.notify("Pi Attention 当前仅支持 Windows", "error"); const notificationText = await ctx.ui.input("通知显示内容(支持粤语)", "嘢做完喇,下一步点搞?等你吩咐喎"); if (notificationText === undefined) return; const speechText = await ctx.ui.input("语音播报内容(建议使用普通话)", "Pi 已完成当前工作,等待你的下一步指令"); if (speechText === undefined) return; const autostart = await ctx.ui.confirm("Windows 自启动", "是否在登录 Windows 时自动启动托盘提醒器?"); await atomicWrite(CONFIG_FILE, { speechEnabled: true, notificationText: notificationText.trim() || "Pi 已完成当前工作,等待你的下一步指令", speechText: speechText.trim() || "Pi 已完成当前工作,等待你的下一步指令", autostart, repeatIntervalMinutes: 5 }); startTrayBestEffort(); try { spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", LAUNCHER_SCRIPT, "-DataDir", DATA_DIR, "-ScriptPath", TRAY_SCRIPT, "-GenerateSound"], { stdio: "ignore", windowsHide: true }); } catch (error) { console.error("[pi-attention] setup 后台操作失败", error); } ctx.ui.notify("配置已保存;托盘程序会生成一次固定播报音频。以后修改语音内容后,请执行 /attention:reset 使新语音生效", "info"); }}); pi.on("session_shutdown", async () => { if (heartbeat) clearInterval(heartbeat); if (statePath) { try { await unlink(statePath); } catch (error: any) { if (error.code !== "ENOENT") console.error("[pi-attention] 状态清理失败", error); } } }); }