theniceboy/opencode/tui-plugins/tracker-notify.ts
2026-03-27 21:04:47 -07:00

279 lines
8 KiB
TypeScript

import { appendFileSync, mkdirSync, renameSync, writeFileSync } from "fs";
const NOTIFY_BIN = "/usr/bin/python3";
const NOTIFY_SCRIPT = "/Users/david/.config/codex/notify.py";
const MAX_SUMMARY_CHARS = 600;
const TRACKER_BIN = "/Users/david/.config/agent-tracker/bin/tracker-client";
const LOG_FILE = "/tmp/tracker-notify-debug.log";
const STATE_ROOT = process.env.XDG_STATE_HOME || `${process.env.HOME || ""}/.local/state`;
const OP_STATE_DIR = `${STATE_ROOT}/op`;
const TMUX_BINS = [process.env.TMUX_BIN, "/opt/homebrew/bin/tmux", "tmux"].filter(Boolean);
const log = (msg: string, data?: any) => {
const timestamp = new Date().toISOString();
const logMsg = `[${timestamp}] ${msg}${data ? " " + JSON.stringify(data) : ""}\n`;
try {
appendFileSync(LOG_FILE, logMsg);
} catch (e) {
// ignore
}
};
export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
const trackerNotifyEnabled = process.env.OP_TRACKER_NOTIFY === "1";
if (!trackerNotifyEnabled) {
return {};
}
// Only run within tmux (TMUX_PANE must be set)
const TMUX_PANE = process.env.TMUX_PANE;
log("Plugin loading, TMUX_PANE:", TMUX_PANE);
if (!TMUX_PANE) {
log("Not in tmux, plugin disabled");
return {};
}
// Resolve tmux context once at startup to avoid race conditions
let tmuxContext = null;
const resolveTmuxContext = async () => {
if (tmuxContext) return tmuxContext;
for (const tmuxBin of TMUX_BINS) {
try {
const output = await $`${tmuxBin} display-message -p -t ${TMUX_PANE} "#{session_id}:::#{window_id}:::#{pane_id}:::#{session_name}:::#{window_index}:::#{pane_index}"`.text();
const parts = output.trim().split(":::");
if (parts.length === 6) {
tmuxContext = {
sessionId: parts[0],
windowId: parts[1],
paneId: parts[2],
sessionName: parts[3],
windowIndex: parts[4],
paneIndex: parts[5],
};
break;
}
} catch {
// continue
}
}
if (!tmuxContext) {
tmuxContext = { paneId: TMUX_PANE };
}
return tmuxContext;
};
await resolveTmuxContext();
const sanitizeKey = (value = "") => value.replace(/[^A-Za-z0-9_]/g, "_");
const paneLocator = () => {
if (!tmuxContext?.sessionName || !tmuxContext?.windowIndex || !tmuxContext?.paneIndex) {
return "";
}
return `${tmuxContext.sessionName}:${tmuxContext.windowIndex}.${tmuxContext.paneIndex}`;
};
const persistPaneSessionMap = async (sessionID) => {
const locator = paneLocator();
if (!sessionID || !locator) return;
const stateFile = `${OP_STATE_DIR}/loc_${sanitizeKey(locator)}`;
try {
mkdirSync(OP_STATE_DIR, { recursive: true });
const tmpPath = `${stateFile}.tmp`;
writeFileSync(tmpPath, `${sessionID}\n`, "utf8");
renameSync(tmpPath, stateFile);
} catch (error) {
log("failed to persist pane session", { locator, error: String(error) });
}
};
const eventSessionID = (event) => {
return (
event?.properties?.sessionID ||
event?.properties?.session?.id ||
event?.properties?.info?.id ||
""
);
};
let taskActive = false;
let currentSessionID = null;
let lastUserMessage = "";
const trackerReady = async () => {
const check = await $`test -x ${TRACKER_BIN}`.nothrow();
return check?.exitCode === 0;
};
const buildTrackerArgs = () => {
const args = [];
if (tmuxContext?.sessionId) args.push("-session-id", tmuxContext.sessionId);
if (tmuxContext?.windowId) args.push("-window-id", tmuxContext.windowId);
if (tmuxContext?.paneId) args.push("-pane", tmuxContext.paneId);
return args;
};
const summarizeText = (parts = []) => {
const text = parts
.filter((p) => p?.type === "text" && !p.ignored)
.map((p) => p.text || "")
.join("\n")
.trim();
return text.slice(0, MAX_SUMMARY_CHARS);
};
const collectUserInputs = (messages) => {
return messages
.filter((m) => m?.info?.role === "user")
.slice(-3)
.map((m) => summarizeText(m.parts))
.filter((text) => text);
};
const startTask = async (summary, sessionID) => {
if (!summary) return;
if (!(await trackerReady())) return;
taskActive = true;
currentSessionID = sessionID;
const args = buildTrackerArgs();
await $`${TRACKER_BIN} command ${args} -summary ${summary} start_task`.nothrow();
};
const finishTask = async (summary) => {
if (!taskActive) return;
if (!(await trackerReady())) return;
taskActive = false;
currentSessionID = null;
const args = buildTrackerArgs();
await $`${TRACKER_BIN} command ${args} -summary ${summary || "done"} finish_task`.nothrow();
};
const notify = async (sessionID) => {
try {
const messages =
(await client.session.messages({
path: { id: sessionID },
query: { directory },
})) || [];
const assistant = [...messages]
.reverse()
.find((m) => m?.info?.role === "assistant");
if (!assistant) return;
const assistantText = summarizeText(assistant.parts);
if (!assistantText) return;
const payload = {
type: "agent-turn-complete",
"last-assistant-message": assistantText,
input_messages: collectUserInputs(messages),
};
const serialized = JSON.stringify(payload);
try {
await $`${NOTIFY_BIN} ${NOTIFY_SCRIPT} ${serialized}`;
} catch {
// ignore notification failures
}
} catch {
// Ignore notification failures
}
};
const getLastMessageText = async (sessionID, role, retries = 3) => {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const messages =
(await client.session.messages({
path: { id: sessionID },
query: { directory },
})) || [];
const msg = [...messages]
.reverse()
.find((m) => m?.info?.role === role);
if (msg) {
const text = summarizeText(msg.parts);
if (text) return text;
}
} catch {
// ignore fetch errors
}
if (attempt < retries - 1) {
await new Promise((r) => setTimeout(r, 100));
}
}
return "";
};
// Track message IDs to their roles
const messageRoles = new Map();
return {
"tool.execute.before": async (input, output) => {
if (input.tool === "question") {
log("Question tool called:", {
questions: output.args?.questions || "no questions",
timestamp: new Date().toISOString()
});
}
},
event: async ({ event }) => {
// Track message roles from message.updated events
if (event?.type === "message.updated") {
const info = event?.properties?.info;
if (info?.id && info?.role) {
messageRoles.set(info.id, info.role);
}
}
// Capture user message text from message.part.updated
if (event?.type === "message.part.updated") {
const part = event?.properties?.part;
if (part?.type === "text" && part?.text && part?.messageID) {
const role = messageRoles.get(part.messageID);
// Capture if it's a user message, or if we're not yet in a task (user input comes first)
if (role === "user" || (!role && !taskActive)) {
const text = part.text?.trim();
if (text && text.length > 0) {
lastUserMessage = text.slice(0, MAX_SUMMARY_CHARS);
}
}
}
}
if (event?.type !== "session.updated" && event?.type !== "session.status") return;
const sessionID = eventSessionID(event);
if (!sessionID) return;
// Check if this is a subagent session
const session = await client.session.get({ path: { id: sessionID } }).catch(() => null);
const parentID = session?.data?.parentID;
// Skip subagent sessions (they have a parentID)
if (parentID) {
return;
}
await persistPaneSessionMap(sessionID);
if (event?.type !== "session.status") return;
const status = event?.properties?.status;
if (!status) return;
if (status.type === "busy" && !taskActive) {
// Use captured message first, then fall back to API
let text = lastUserMessage;
if (!text) {
text = await getLastMessageText(sessionID, "user");
}
await startTask(text || "working...", sessionID);
lastUserMessage = "";
} else if (status.type === "idle" && taskActive) {
if (currentSessionID && sessionID !== currentSessionID) return;
const text = await getLastMessageText(sessionID, "assistant");
await finishTask(text || "done");
await notify(sessionID);
}
},
};
};