From c56de89d3d6a38e25aaf8ff05ecd34f2b4db73e9 Mon Sep 17 00:00:00 2001 From: David Chen Date: Thu, 9 Apr 2026 10:41:50 -0700 Subject: [PATCH] agent tracker + tmux + opencode updates --- opencode/AGENTS.md | 6 +- opencode/plugins/require_work_summary.ts | 108 ----------------------- opencode/tui-plugins/tracker-notify.ts | 97 ++++++++++++++++++-- tmux/scripts/pane_starship_title.sh | 8 +- tmux/tmux-status/left.sh | 12 ++- tmux/tmux-status/window_task_icon.sh | 8 +- 6 files changed, 117 insertions(+), 122 deletions(-) delete mode 100644 opencode/plugins/require_work_summary.ts diff --git a/opencode/AGENTS.md b/opencode/AGENTS.md index e80c27b..a9f4e51 100644 --- a/opencode/AGENTS.md +++ b/opencode/AGENTS.md @@ -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. diff --git a/opencode/plugins/require_work_summary.ts b/opencode/plugins/require_work_summary.ts deleted file mode 100644 index cb5e667..0000000 --- a/opencode/plugins/require_work_summary.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Plugin } from "@opencode-ai/plugin" - -type TurnState = { - summaryCalled: boolean -} - -const states = new Map() -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 diff --git a/opencode/tui-plugins/tracker-notify.ts b/opencode/tui-plugins/tracker-notify.ts index 64d06a4..aa4d516 100644 --- a/opencode/tui-plugins/tracker-notify.ts +++ b/opencode/tui-plugins/tracker-notify.ts @@ -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; diff --git a/tmux/scripts/pane_starship_title.sh b/tmux/scripts/pane_starship_title.sh index 3c1193d..a05de74 100755 --- a/tmux/scripts/pane_starship_title.sh +++ b/tmux/scripts/pane_starship_title.sh @@ -61,11 +61,12 @@ load_option() { printf '%s' "$value" } -clear_work_labels() { +clear_opencode_state() { [[ -n "$pane_id" ]] || return 0 tmux set-option -p -u -t "$pane_id" @op_work_theme 2>/dev/null || true tmux set-option -p -u -t "$pane_id" @op_work_now 2>/dev/null || true tmux set-option -p -u -t "$pane_id" @op_work_summary 2>/dev/null || true + tmux set-option -p -u -t "$pane_id" @op_question_pending 2>/dev/null || true } opencode_active() { @@ -102,10 +103,11 @@ if [[ -z "$theme" ]]; then theme=$(load_option "@op_work_summary") fi now=$(load_option "@op_work_now") +question_pending=$(load_option "@op_question_pending") if ! opencode_active; then - if [[ -n "$theme" || -n "$now" ]]; then - clear_work_labels + if [[ -n "$theme" || -n "$now" || -n "$question_pending" ]]; then + clear_opencode_state fi printf '%s' "$title" exit 0 diff --git a/tmux/tmux-status/left.sh b/tmux/tmux-status/left.sh index 737ad4e..9c60bc9 100755 --- a/tmux/tmux-status/left.sh +++ b/tmux/tmux-status/left.sh @@ -61,9 +61,15 @@ if [[ -f "$CACHE_FILE" ]]; then tracker_state=$(cat "$CACHE_FILE" 2>/dev/null || true) fi +question_state=$(tmux list-panes -a -F '#{session_id}::#{@op_question_pending}' 2>/dev/null || true) + get_session_icon() { local sid="$1" - local has_bell=0 has_watch=0 + local has_question=0 has_bell=0 has_watch=0 + + local question_pane + question_pane=$(grep -F -m1 -x "${sid}::1" <<< "$question_state" || true) + [[ -n "$question_pane" ]] && has_question=1 local unread_win unread_win=$(tmux list-windows -t "$sid" -F '#{@unread}' 2>/dev/null | grep -m1 '^1$' || true) @@ -87,7 +93,9 @@ get_session_icon() { esac fi - if (( has_bell )); then + if (( has_question )); then + printf '❓' + elif (( has_bell )); then printf '🔔' elif (( has_watch )); then printf '⏳' diff --git a/tmux/tmux-status/window_task_icon.sh b/tmux/tmux-status/window_task_icon.sh index 7a218c5..ce89b06 100755 --- a/tmux/tmux-status/window_task_icon.sh +++ b/tmux/tmux-status/window_task_icon.sh @@ -8,11 +8,15 @@ watching="${3:-0}" has_bell=0 has_watch=0 +has_question=0 [[ "$unread" == "1" ]] && has_bell=1 [[ "$watching" == "1" ]] && has_watch=1 +question_pane=$(tmux list-panes -t "$window_id" -F '#{@op_question_pending}' 2>/dev/null | grep -F -m1 -x '1' || true) +[[ -n "$question_pane" ]] && has_question=1 + CACHE_FILE="/tmp/tmux-tracker-cache.json" if [[ -f "$CACHE_FILE" ]]; then state=$(cat "$CACHE_FILE" 2>/dev/null || true) @@ -30,7 +34,9 @@ if [[ -f "$CACHE_FILE" ]]; then fi fi -if (( has_bell )); then +if (( has_question )); then + printf '❓' +elif (( has_bell )); then printf '🔔' elif (( has_watch )); then printf '⏳'