agent tracker + tmux + opencode updates

This commit is contained in:
David Chen 2026-04-09 10:41:50 -07:00
parent 75403c927f
commit c56de89d3d
6 changed files with 117 additions and 122 deletions

View file

@ -6,9 +6,9 @@
## Work Summary
- `set_work_summary` is mandatory protocol, not a suggestion.
- It is fine to inspect first. You may use exploratory tools like search, read, task, web, or read-only shell commands before calling `set_work_summary`.
- Call `set_work_summary` once you have enough context to label the work accurately, and always before edits, patches, or other committed actions.
- Keep `set_work_summary` up to date during the turn.
- You may call any tools before `set_work_summary` when you need more context.
- Call `set_work_summary` once you understand the work clearly enough to label it well, and update it again when the focus materially changes.
- Prefer calling it with both fields: `set_work_summary({ theme: "...", now: "..." })`.
- `theme` answers: what is this pane about overall? Keep it stable across many turns.
- `now` answers: what are you about to do next? Update it whenever the next concrete step changes.

View file

@ -1,108 +0,0 @@
import type { Plugin } from "@opencode-ai/plugin"
type TurnState = {
summaryCalled: boolean
}
const states = new Map<string, TurnState>()
const exploratoryTools = new Set([
"bash",
"read",
"glob",
"grep",
"task",
"question",
"webfetch",
"web_google_search",
"web_website_fetch",
"web_website_search",
"web_website_outline",
"web_website_extract_section",
"web_website_extract_pricing",
])
function sessionState(sessionID: string) {
let state = states.get(sessionID)
if (!state) {
state = { summaryCalled: false }
states.set(sessionID, state)
}
return state
}
function eventSessionID(event: any) {
return String(
event?.properties?.sessionID ||
event?.properties?.session?.id ||
event?.properties?.info?.sessionID ||
"",
).trim()
}
function isExploratoryTool(tool: string) {
return exploratoryTools.has(tool)
}
export const RequireWorkSummaryPlugin: Plugin = async () => {
return {
"chat.message": async (input: any) => {
const sessionID = String(input?.sessionID || "").trim()
if (!sessionID) {
return
}
sessionState(sessionID).summaryCalled = false
},
"tool.execute.before": async (input: any) => {
const sessionID = String(input?.sessionID || "").trim()
if (!sessionID) {
return
}
const tool = String(input?.tool || "").trim()
if (!tool) {
return
}
const state = sessionState(sessionID)
if (tool === "set_work_summary") {
return
}
if (isExploratoryTool(tool)) {
return
}
if (!state.summaryCalled) {
throw new Error(
"Exploration tools can run first, but call set_work_summary before edits or other committed actions.",
)
}
},
"tool.execute.after": async (input: any) => {
const sessionID = String(input?.sessionID || "").trim()
if (!sessionID) {
return
}
const tool = String(input?.tool || "").trim()
if (tool === "set_work_summary") {
sessionState(sessionID).summaryCalled = true
}
},
event: async ({ event }) => {
if (event?.type === "session.deleted") {
const sessionID = eventSessionID(event)
if (sessionID) {
states.delete(sessionID)
}
return
}
},
}
}
export default RequireWorkSummaryPlugin

View file

@ -1,4 +1,4 @@
import { appendFileSync, mkdirSync, renameSync, writeFileSync } from "fs";
import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
const NOTIFY_BIN = "/usr/bin/python3";
const NOTIFY_SCRIPT = "/Users/david/.config/codex/notify.py";
@ -8,6 +8,7 @@ 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 TMUX_QUESTION_OPTION = "@op_question_pending";
const log = (msg: string, data?: any) => {
const timestamp = new Date().toISOString();
@ -70,17 +71,30 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
}
return `${tmuxContext.sessionName}:${tmuxContext.windowIndex}.${tmuxContext.paneIndex}`;
};
const persistPaneSessionMap = async (sessionID) => {
const paneSessionStateFile = () => {
const locator = paneLocator();
if (!sessionID || !locator) return;
const stateFile = `${OP_STATE_DIR}/loc_${sanitizeKey(locator)}`;
if (!locator) return "";
return `${OP_STATE_DIR}/loc_${sanitizeKey(locator)}`;
};
const persistPaneSessionMap = async (sessionID) => {
const stateFile = paneSessionStateFile();
if (!sessionID || !stateFile) return;
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) });
log("failed to persist pane session", { stateFile, error: String(error) });
}
};
const loadPersistedPaneSessionID = () => {
const stateFile = paneSessionStateFile();
if (!stateFile) return "";
try {
return readFileSync(stateFile, "utf8").trim();
} catch {
return "";
}
};
const eventSessionID = (event) => {
@ -95,6 +109,60 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
let taskActive = false;
let currentSessionID = null;
let lastUserMessage = "";
let rootSessionID = loadPersistedPaneSessionID();
let questionPending: boolean | null = null;
const setTmuxPaneOption = async (option, value: string | null) => {
if (!tmuxContext?.paneId) return;
for (const tmuxBin of TMUX_BINS) {
const cmd =
value === null
? await $`${tmuxBin} set-option -p -u -t ${tmuxContext.paneId} ${option}`.nothrow()
: await $`${tmuxBin} set-option -p -t ${tmuxContext.paneId} ${option} ${value}`.nothrow();
if (cmd?.exitCode === 0) {
return;
}
}
};
const applyQuestionPending = async (pending: boolean) => {
if (questionPending === pending) return;
questionPending = pending;
await setTmuxPaneOption(TMUX_QUESTION_OPTION, pending ? "1" : null);
};
const listPendingQuestions = async () => {
try {
const response = await client.question.list({ directory });
if (Array.isArray(response?.data)) {
return response.data;
}
if (Array.isArray(response)) {
return response;
}
} catch {
// ignore
}
return [];
};
const syncPendingQuestionState = async (sessionID = rootSessionID) => {
const effectiveSessionID = sessionID || loadPersistedPaneSessionID();
if (!effectiveSessionID) {
return;
}
rootSessionID = effectiveSessionID;
const pending = (await listPendingQuestions()).some(
(question) => question?.sessionID === effectiveSessionID,
);
await applyQuestionPending(pending);
};
if (rootSessionID) {
await syncPendingQuestionState(rootSessionID);
} else {
await applyQuestionPending(false);
}
const trackerReady = async () => {
const check = await $`test -x ${TRACKER_BIN}`.nothrow();
@ -208,6 +276,10 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
return {
"tool.execute.before": async (input, output) => {
if (input.tool === "question") {
if (!rootSessionID && input?.sessionID) {
rootSessionID = input.sessionID;
}
await applyQuestionPending(true);
log("Question tool called:", {
questions: output.args?.questions || "no questions",
timestamp: new Date().toISOString()
@ -216,6 +288,16 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
},
event: async ({ event }) => {
if (event?.type === "question.asked") {
await applyQuestionPending(true);
return;
}
if (event?.type === "question.replied" || event?.type === "question.rejected") {
const sessionID = event?.properties?.sessionID || rootSessionID;
await syncPendingQuestionState(sessionID);
return;
}
// Track message roles from message.updated events
if (event?.type === "message.updated") {
@ -254,7 +336,12 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
return;
}
const sessionChanged = sessionID !== rootSessionID;
rootSessionID = sessionID;
await persistPaneSessionMap(sessionID);
if (sessionChanged) {
await syncPendingQuestionState(sessionID);
}
if (event?.type !== "session.status") return;
const status = event?.properties?.status;