agent tracker updates

This commit is contained in:
David Chen 2026-01-17 14:01:39 -08:00
parent 689cc061cd
commit bb2a53206b
13 changed files with 375 additions and 106 deletions

View file

@ -4,14 +4,59 @@ const MAX_SUMMARY_CHARS = 600;
const TRACKER_BIN = "/Users/david/.config/agent-tracker/bin/tracker-client";
export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
// Only run within tmux (TMUX_PANE must be set)
const TMUX_PANE = process.env.TMUX_PANE;
if (!TMUX_PANE) {
return {};
}
// Resolve tmux context once at startup to avoid race conditions
let tmuxContext = null;
const resolveTmuxContext = async () => {
if (tmuxContext) return tmuxContext;
try {
const result = await $`tmux display-message -p -t ${TMUX_PANE} "#{session_id}:::#{window_id}:::#{pane_id}"`.quiet();
const parts = result.stdout.trim().split(":::");
if (parts.length === 3) {
tmuxContext = {
sessionId: parts[0],
windowId: parts[1],
paneId: parts[2],
};
}
} catch {
// Fallback: use TMUX_PANE directly
tmuxContext = { paneId: TMUX_PANE };
}
return tmuxContext;
};
await resolveTmuxContext();
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;
};
// On init: finish any stale task for this pane
const finishStaleTask = async () => {
if (!(await trackerReady())) return;
const args = buildTrackerArgs();
await $`${TRACKER_BIN} command ${args} -summary "stale" finish_task`.nothrow();
};
finishStaleTask();
const summarizeText = (parts = []) => {
const text = parts
.filter((p) => p?.type === "text" && !p.ignored)
@ -34,7 +79,8 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
if (!(await trackerReady())) return;
taskActive = true;
currentSessionID = sessionID;
await $`${TRACKER_BIN} command -summary ${summary} start_task`.nothrow();
const args = buildTrackerArgs();
await $`${TRACKER_BIN} command ${args} -summary ${summary} start_task`.nothrow();
};
const finishTask = async (summary) => {
@ -42,7 +88,8 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
if (!(await trackerReady())) return;
taskActive = false;
currentSessionID = null;
await $`${TRACKER_BIN} command -summary ${summary || "done"} finish_task`.nothrow();
const args = buildTrackerArgs();
await $`${TRACKER_BIN} command ${args} -summary ${summary || "done"} finish_task`.nothrow();
};
const notify = async (sessionID) => {
@ -78,87 +125,79 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
}
};
const isSessionTrulyIdle = async (sessionID) => {
try {
const messages =
(await client.session.messages({
path: { id: sessionID },
query: { directory },
})) || [];
if (!messages.length) return true;
const last = messages[messages.length - 1];
if (!last?.info) return true;
// If last message is from user, assistant hasn't responded yet
if (last.info.role === "user") return false;
// If last message is assistant, check if it has completed
return !!last.info.time?.completed;
} catch {
return true;
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 "";
};
const tryFinishTask = async (sessionID) => {
if (!taskActive) return;
// Only finish for the session that started the task
if (currentSessionID && sessionID !== currentSessionID) return;
// Verify session is truly idle (assistant message completed)
if (!(await isSessionTrulyIdle(sessionID))) return;
let text = "";
try {
const messages =
(await client.session.messages({
path: { id: sessionID },
query: { directory },
})) || [];
const assistant = [...messages]
.reverse()
.find((m) => m?.info?.role === "assistant");
if (assistant) text = summarizeText(assistant.parts);
} catch {
// ignore fetch errors
}
await finishTask(text || "done");
await notify(sessionID);
};
// Track message IDs to their roles
const messageRoles = new Map();
return {
event: async ({ event }) => {
// session.idle event - verify with message state
if (event?.type === "session.idle" && event?.properties?.sessionID) {
await tryFinishTask(event.properties.sessionID);
return;
}
// session.status event - "idle" status is more reliable
if (event?.type === "session.status") {
const sessionID = event?.properties?.sessionID;
const status = event?.properties?.status;
if (sessionID && status === "idle") {
await tryFinishTask(sessionID);
// 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);
}
return;
}
},
"chat.message": async (_input, output) => {
if (output?.message?.role !== "user") return;
const sessionID = output?.message?.sessionID;
const summary = summarizeText(output.parts).slice(0, 200);
await startTask(summary, sessionID);
},
"message.updated": async ({ event }) => {
// When an assistant message is updated with time.completed, the turn is done
if (event?.properties?.info?.role !== "assistant") return;
if (!event?.properties?.info?.time?.completed) return;
const sessionID = event?.properties?.info?.sessionID;
if (!sessionID) return;
// 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);
}
}
}
}
await tryFinishTask(sessionID);
if (event?.type !== "session.status") return;
const sessionID = event?.properties?.sessionID;
const status = event?.properties?.status;
if (!sessionID || !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);
}
},
};
};