From d4c99b70cda66b04700882e58ecc8fa5a2bf54b1 Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Sun, 3 May 2026 17:33:40 -0400 Subject: [PATCH 1/3] fix(plugins): address security and reliability issues in 4 plugins - xpowers-notify.ts: Fix shell injection in osascript/notify-send via JSON.stringify() and Bun shell safe argument passing. Add OSC sequence escaping to prevent terminal injection. - xpowers-task-monitor.ts: Fix setInterval timer leak with isShuttingDown flag and centralized stopPolling(). Fix task parser regex to support multi-digit priorities (P10+). - xpowers-lint-gate.ts: Fix stderr capture from Bun shell results. Fix command construction to pass args as array for safe execution. Add linter detection caching per extension to avoid repeated 'which' calls. - xpowers-git-guard.ts: Fix git status parsing for rename entries (R status). Scope autoCommit to only files modified during session instead of 'git add -A'. Add session cleanup to prevent state leak. --- .opencode/plugins/xpowers-git-guard.ts | 45 ++++++++++++++++++++--- .opencode/plugins/xpowers-lint-gate.ts | 20 ++++++++-- .opencode/plugins/xpowers-notify.ts | 30 ++++++++++----- .opencode/plugins/xpowers-task-monitor.ts | 19 +++++++--- 4 files changed, 91 insertions(+), 23 deletions(-) diff --git a/.opencode/plugins/xpowers-git-guard.ts b/.opencode/plugins/xpowers-git-guard.ts index e9360c86..e86c46f2 100644 --- a/.opencode/plugins/xpowers-git-guard.ts +++ b/.opencode/plugins/xpowers-git-guard.ts @@ -126,8 +126,19 @@ const getGitStatus = async ($: any, cwd: string): Promise => { } // Status line: XY filename or XY "filename with spaces" + // Renames: XY "old" -> "new" (R status in index or worktree) const statusCode = line.slice(0, 2) - const filePath = line.slice(3).replace(/^"(.*)"$/, "$1") + let filePath = line.slice(3) + + // Handle rename entries: R "old/path" -> "new/path" + if (statusCode[0] === "R" || statusCode[1] === "R") { + const renameMatch = line.match(/^\S{2}\s+(.+?)\s+->\s+(.+)$/) + if (renameMatch) { + filePath = renameMatch[2].replace(/^"(.*)"$/, "$1") + } + } else { + filePath = filePath.replace(/^"(.*)"$/, "$1") + } if (!filePath) continue @@ -150,6 +161,12 @@ const getGitStatus = async ($: any, cwd: string): Promise => { if (worktreeStatus === "?") { status.untrackedFiles.push(filePath) } + // Renames count as modified + if (indexStatus === "R" || worktreeStatus === "R") { + if (!status.modifiedFiles.includes(filePath)) { + status.modifiedFiles.push(filePath) + } + } } return status @@ -180,9 +197,19 @@ const hasUncommittedChanges = async ($: any, cwd: string): Promise => { return status.hasChanges } -const autoCommit = async ($: any, cwd: string, message: string): Promise<{ ok: boolean; error?: string }> => { +const autoCommit = async ( + $: any, + cwd: string, + message: string, + filesToCommit: string[], +): Promise<{ ok: boolean; error?: string }> => { + if (filesToCommit.length === 0) { + return { ok: true } + } + try { - const addResult = await $`git -C ${cwd} add -A`.quiet().nothrow() + // Only stage files that were modified during this session, not everything + const addResult = await $`git -C ${cwd} add ${filesToCommit}`.quiet().nothrow() if (addResult.exitCode !== 0) { return { ok: false, error: "git add failed" } } @@ -317,8 +344,9 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => { if (event.type === "session.deleted" && sessionId) { const state = sessions.get(sessionId) if (state && config.autoCommitOnSessionEnd && state.filesModified.size > 0 && !state.commitMade) { - // Auto-commit on session end - const result = await autoCommit(ctx.$, ctx.directory, config.autoCommitMessage) + // Auto-commit on session end — only commit files modified during this session + const filesToCommit = Array.from(state.filesModified) + const result = await autoCommit(ctx.$, ctx.directory, config.autoCommitMessage, filesToCommit) if (result.ok) { await showToast( ctx.client, @@ -338,6 +366,13 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => { } } sessions.delete(sessionId) + // Cleanup orphaned sessions older than 24 hours + const cutoff = Date.now() - 86400000 + for (const [id, s] of sessions) { + if (!s) { + sessions.delete(id) + } + } return } diff --git a/.opencode/plugins/xpowers-lint-gate.ts b/.opencode/plugins/xpowers-lint-gate.ts index 5b2b8cda..448d70d5 100644 --- a/.opencode/plugins/xpowers-lint-gate.ts +++ b/.opencode/plugins/xpowers-lint-gate.ts @@ -427,9 +427,12 @@ const runLinter = async ( ? [...linter.args, ...linter.fixArgs, filePath] : [...linter.args, filePath] - const result = await $`${linter.command} ${args}`.quiet().nothrow() + // Build command as array for Bun shell to safely pass each arg + const cmdParts = [linter.command, ...args] + const result = await $`${cmdParts}`.quiet().nothrow() const stdout = await result.text() - const stderr = "" + // Bun shell returns stderr as a property on the result object + const stderr = typeof result.stderr === "string" ? result.stderr : "" const issues = linter.parseOutput(stdout, stderr) @@ -524,6 +527,9 @@ const xpowersLintGatePlugin: Plugin = async (ctx) => { return {} } + // Cache linter detection results per extension to avoid repeated `which` calls + const linterCache = new Map() + // Track recent toasts to avoid spam const lastToastTime = new Map() @@ -570,8 +576,14 @@ const xpowersLintGatePlugin: Plugin = async (ctx) => { }, } } else { - // Auto-detect project linter - linter = await detectProjectLinter(ctx.$, ctx.directory, filePath) + // Auto-detect project linter (cached per extension) + const ext = extname(filePath).toLowerCase() + if (linterCache.has(ext)) { + linter = linterCache.get(ext) ?? null + } else { + linter = await detectProjectLinter(ctx.$, ctx.directory, filePath) + linterCache.set(ext, linter) + } } if (!linter) { diff --git a/.opencode/plugins/xpowers-notify.ts b/.opencode/plugins/xpowers-notify.ts index 1028d495..a1879e29 100644 --- a/.opencode/plugins/xpowers-notify.ts +++ b/.opencode/plugins/xpowers-notify.ts @@ -96,7 +96,12 @@ const checkBackend = async ($: any, backend: Backend): Promise => { // ── Notification Sending ──────────────────────────────────────────────────── -const escapeShell = (text: string) => text.replace(/"/g, '\\"') +/** + * Escape OSC sequence content to prevent terminal injection. + * OSC 777/99 payloads must not contain BEL (\x07) or ESC (\x1b). + */ +const escapeOsc = (text: string): string => + text.replace(/\x1b/g, "").replace(/\x07/g, "") const sendDesktopNotification = async ( $: any, @@ -106,24 +111,30 @@ const sendDesktopNotification = async ( ): Promise => { try { switch (backend) { - case "osascript": - await $`osascript -e ${ - `display notification "${escapeShell(message)}" with title "${escapeShell(title)}"` - }`.quiet().nothrow() + case "osascript": { + // Use Bun shell template literal which safely escapes arguments + const script = `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)}` + await $`osascript -e ${script}`.quiet().nothrow() return true + } - case "notify-send": + case "notify-send": { + // Bun template literal safely passes arguments to subprocess await $`notify-send ${title} ${message}`.quiet().nothrow() return true + } - case "growlnotify": + case "growlnotify": { await $`growlnotify -t ${title} -m ${message}`.quiet().nothrow() return true + } case "osc777": { // OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode // Format: ESC ] 777 ; notify ; title ; body BEL - const osc = `\x1b]777;notify;${title};${message}\x07` + const safeTitle = escapeOsc(title) + const safeMessage = escapeOsc(message) + const osc = `\x1b]777;notify;${safeTitle};${safeMessage}\x07` process.stdout.write(osc) return true } @@ -131,7 +142,8 @@ const sendDesktopNotification = async ( case "osc99": { // OSC 99: Kitty // Format: ESC ] 99 ; i=1:d=0 ; body ESC \ - const osc = `\x1b]99;i=1:d=0;${message}\x1b\\` + const safeMessage = escapeOsc(message) + const osc = `\x1b]99;i=1:d=0;${safeMessage}\x1b\\` process.stdout.write(osc) return true } diff --git a/.opencode/plugins/xpowers-task-monitor.ts b/.opencode/plugins/xpowers-task-monitor.ts index 376bcc7e..5cd1ed17 100644 --- a/.opencode/plugins/xpowers-task-monitor.ts +++ b/.opencode/plugins/xpowers-task-monitor.ts @@ -102,8 +102,9 @@ const parseTasks = (output: string): ParsedTask[] => { // Match: [indicator] [id] [status] P[priority] [title] // ○ hyper-5ct ● P1 [epic] Project: Rename repository to xpowers + // Supports priorities P0-P99 (multi-digit) const match = trimmed.match( - /^[○◐●✓❄]\s+([a-z]+-[a-z0-9]+)\s+.*?P(\d)\s+(.+)/, + /^[○◐●✓❄]\s+([a-z]+-[a-z0-9]+)\s+.*?P(\d+)\s+(.+)/, ) if (match) { tasks.push({ @@ -187,6 +188,7 @@ const xpowersTaskMonitorPlugin: Plugin = async (ctx) => { let lastTaskCount = 0 let pollTimer: ReturnType | null = null let isPolling = false + let isShuttingDown = false const notifyNewTasks = async (tasks: ParsedTask[]) => { const newTasks = tasks.filter((t) => !seenTasks.has(t.id)) @@ -242,7 +244,7 @@ const xpowersTaskMonitorPlugin: Plugin = async (ctx) => { } const doPoll = async () => { - if (isPolling) return + if (isPolling || isShuttingDown) return isPolling = true try { @@ -265,6 +267,14 @@ const xpowersTaskMonitorPlugin: Plugin = async (ctx) => { } } + const stopPolling = () => { + isShuttingDown = true + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } + // Start background polling if (config.pollIntervalMs >= 5000) { pollTimer = setInterval(doPoll, config.pollIntervalMs) @@ -323,9 +333,8 @@ const xpowersTaskMonitorPlugin: Plugin = async (ctx) => { } // Cleanup timer when session ends - if (event.type === "session.deleted" && pollTimer) { - clearInterval(pollTimer) - pollTimer = null + if (event.type === "session.deleted") { + stopPolling() } }, From 759877398dba54dacac0fbceb6cea04619d3c036 Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Mon, 4 May 2026 02:01:58 -0400 Subject: [PATCH 2/3] fix(plugins): second-pass fixes for slow-mode, context-gauge, git-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xpowers-slow-mode.ts: Fix matchGlob regex special-char escaping. Pattern '.env' no longer matches 'aenv' (dot was unescaped regex). Add createdAt tracking and cleanupOldSessions() to prevent memory leak. - xpowers-context-gauge.ts: Fix resolveModelLimit partial matching bug where 'gpt-4' would incorrectly match 'gpt-4.1' limit (1M vs 8192). Add message ID deduplication to prevent double-counting tokens on streaming updates. Add createdAt tracking and session cleanup. - xpowers-git-guard.ts: Fix useless cleanup loop from first fix — now properly checks createdAt timestamp and removes orphaned sessions. --- .opencode/plugins/xpowers-context-gauge.ts | 40 +++++++++++++++++++--- .opencode/plugins/xpowers-git-guard.ts | 20 +++++++---- .opencode/plugins/xpowers-slow-mode.ts | 36 ++++++++++++++----- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/.opencode/plugins/xpowers-context-gauge.ts b/.opencode/plugins/xpowers-context-gauge.ts index 83a7298c..b4b7356b 100644 --- a/.opencode/plugins/xpowers-context-gauge.ts +++ b/.opencode/plugins/xpowers-context-gauge.ts @@ -147,15 +147,21 @@ const resolveModelLimit = ( // Try exact match first if (limits[modelId]) return limits[modelId] - // Try normalized match + // Try normalized exact match if (limits[normalized]) return limits[normalized] - // Try partial match on model family + // Try partial match: model ID must contain the key as a prefix segment + // e.g., "gpt-4.1-mini" contains "gpt-4.1" → match + // but "gpt-4" does NOT contain "gpt-4.1" → no match for (const [key, limit] of Object.entries(limits)) { const normalizedKey = key.toLowerCase().replace(/[^a-z0-9.-]/g, "") + if (!normalizedKey || normalizedKey === "default") continue + + // Key must be a prefix of the model ID, followed by a separator or end if ( - normalized.includes(normalizedKey) || - normalizedKey.includes(normalized) + normalized === normalizedKey || + normalized.startsWith(normalizedKey + "-") || + normalized.startsWith(normalizedKey + ".") ) { return limit } @@ -173,6 +179,8 @@ type GaugeState = { modelId: string | null contextLimit: number compactSuggested: boolean + createdAt: number + seenMessageIds: Set // deduplicate token counting on streaming updates } const sessions = new Map() @@ -187,12 +195,23 @@ const getState = (sessionId: string): GaugeState => { modelId: null, contextLimit: DEFAULT_CONFIG.defaultLimit, compactSuggested: false, + createdAt: Date.now(), + seenMessageIds: new Set(), } sessions.set(sessionId, state) } return state } +const cleanupOldGaugeSessions = (ttlMs: number = 86400000) => { + const cutoff = Date.now() - ttlMs + for (const [id, s] of sessions) { + if (s.createdAt < cutoff) { + sessions.delete(id) + } + } +} + // ── Plugin ────────────────────────────────────────────────────────────────── const xpowersContextGaugePlugin: Plugin = async (ctx) => { @@ -218,6 +237,8 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => { modelId: null, contextLimit: config.defaultLimit, compactSuggested: false, + createdAt: Date.now(), + seenMessageIds: new Set(), }) return } @@ -241,6 +262,8 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => { if (event.type === "session.deleted" && sessionId) { sessions.delete(sessionId) + // Cleanup orphaned sessions older than 24 hours + cleanupOldGaugeSessions() return } @@ -248,6 +271,15 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => { const message = (event as any).properties?.message if (!message) return + // Deduplicate: don't double-count the same message on streaming updates + const messageId = message.id ?? (event as any).properties?.messageId + if (messageId && state.seenMessageIds.has(String(messageId))) { + return + } + if (messageId) { + state.seenMessageIds.add(String(messageId)) + } + // Try to extract content from message let content = "" if (message.content) { diff --git a/.opencode/plugins/xpowers-git-guard.ts b/.opencode/plugins/xpowers-git-guard.ts index e86c46f2..96ce4a1f 100644 --- a/.opencode/plugins/xpowers-git-guard.ts +++ b/.opencode/plugins/xpowers-git-guard.ts @@ -41,6 +41,7 @@ type SessionState = { filesCommitted: Set commitMade: boolean warnedOnIdle: boolean + createdAt: number } const DEFAULT_CONFIG: Required = { @@ -259,12 +260,21 @@ const sessions = new Map() const getSessionState = (sessionId: string): SessionState => { let state = sessions.get(sessionId) if (!state) { - state = { filesModified: new Set(), filesCommitted: new Set(), commitMade: false, warnedOnIdle: false } + state = { filesModified: new Set(), filesCommitted: new Set(), commitMade: false, warnedOnIdle: false, createdAt: Date.now() } sessions.set(sessionId, state) } return state } +const cleanupOldGitGuardSessions = (ttlMs: number = 86400000) => { + const cutoff = Date.now() - ttlMs + for (const [id, s] of sessions) { + if (s.createdAt < cutoff) { + sessions.delete(id) + } + } +} + // ── Plugin ────────────────────────────────────────────────────────────────── const xpowersGitGuardPlugin: Plugin = async (ctx) => { @@ -337,6 +347,7 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => { filesCommitted: new Set(), commitMade: false, warnedOnIdle: false, + createdAt: Date.now(), }) return } @@ -367,12 +378,7 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => { } sessions.delete(sessionId) // Cleanup orphaned sessions older than 24 hours - const cutoff = Date.now() - 86400000 - for (const [id, s] of sessions) { - if (!s) { - sessions.delete(id) - } - } + cleanupOldGitGuardSessions() return } diff --git a/.opencode/plugins/xpowers-slow-mode.ts b/.opencode/plugins/xpowers-slow-mode.ts index b46659da..a0a32926 100644 --- a/.opencode/plugins/xpowers-slow-mode.ts +++ b/.opencode/plugins/xpowers-slow-mode.ts @@ -87,16 +87,20 @@ const showToast = async ( // ── Path Matching ─────────────────────────────────────────────────────────── +const escapeRegex = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const matchGlob = (pattern: string, path: string): boolean => { const normalizedPath = path.replace(/\\/g, "/") const normalizedPattern = pattern.replace(/\\/g, "/") // Simple glob matching: ** (any depth), * (any chars within segment) - const regexPattern = normalizedPattern - .replace(/\*\*/g, "\u0000") - .replace(/\*/g, "[^/]*") - .replace(/\u0000/g, ".*") - .replace(/\?/g, ".") + // Escape regex special chars in the pattern first, then restore glob wildcards + const regexPattern = escapeRegex(normalizedPattern) + .replace(/\\\*\\\*/g, "\u0000") // restore ** + .replace(/\\\*/g, "[^/]*") // restore * + .replace(/\u0000/g, ".*") // ** = any depth + .replace(/\\\?/g, ".") // restore ? const regex = new RegExp(`^(.*/)?${regexPattern}$`, "i") return regex.test(normalizedPath) @@ -230,20 +234,32 @@ const xpowersSlowModePlugin: Plugin = async (ctx) => { } // Per-session state tracking - const sessions = new Map // filePath -> original content - }>() + createdAt: number + } - const getSessionState = (sessionId: string) => { + const sessions = new Map() + + const getSessionState = (sessionId: string): SlowModeSession => { let state = sessions.get(sessionId) if (!state) { - state = { changes: [], pendingOriginals: new Map() } + state = { changes: [], pendingOriginals: new Map(), createdAt: Date.now() } sessions.set(sessionId, state) } return state } + const cleanupOldSessions = (ttlMs: number = 86400000) => { + const cutoff = Date.now() - ttlMs + for (const [id, s] of sessions) { + if (s.createdAt < cutoff) { + sessions.delete(id) + } + } + } + const logDir = join(ctx.directory, config.logDir) return { @@ -378,6 +394,8 @@ const xpowersSlowModePlugin: Plugin = async (ctx) => { await logSessionSummary(logDir, sessionId, state.changes) sessions.delete(sessionId) } + // Cleanup orphaned sessions older than 24 hours + cleanupOldSessions() return } From f2d8780be4eb89e99c1419561e04f864d3923ba7 Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Mon, 4 May 2026 02:37:38 -0400 Subject: [PATCH 3/3] fix(plugins): third-pass fixes for all 6 plugins from PR review threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xpowers-notify.ts: - Fix early return blocking session.compacted when onAgentIdle=false (Comments 4,19,23) — move guard inside session.idle branch - Fix onTaskError=false not suppressing error notifications (Comment 5) - Fix OSC backends always chosen first, never fallback (Comments 9,18) — reorder defaults to prefer native OS notifications, add cache invalidation on OSC failure xpowers-task-monitor.ts: - Fix timer never restarted after session.deleted (Comment 2) — timer is per-plugin, not per-session; don't stop on session.deleted - Fix parser not supporting non-priority tm formats (Comment 6) — add fallback regex for ENG_CORE-123 style tasks without priority field - Fix trackSeenTasks=false having no effect (Comments 7,27) xpowers-lint-gate.ts: - Fix auto-fix combining check+fix args breaking black/rustfmt (Comment 3) — use fixArgs ONLY when autoFix is enabled - Fix ESLint --format compact not matching parser (Comments 8,16) — remove --format compact, use default stylish output - Fix ShellCheck severity normalization (Comment 15) — map info/style to appropriate LintIssue severity values xpowers-context-gauge.ts: - Fix modelLimits shallow merge losing defaults (Comment 10) — deep merge with DEFAULT_MODEL_LIMITS - Fix suggestCompactAt compared to dangerThreshold not usage (Comments 12,24) — compare against live usage percentage - Fix provider-prefixed model IDs matching wrong entry (Comment 1) — strip provider prefix, prefer longest match - Fix incremental token counting for streaming (Comments 11,26) — track messageContents Map, count delta tokens only xpowers-git-guard.ts: - Fix git diff --stat regex on partial output (Comment 13) — make insertions/deletions groups optional - Fix commit tracking querying status post-commit (Comments 14,25) — add tool.execute.before hook to capture staged files BEFORE commit xpowers-slow-mode.ts: - Fix set-based diff dropping duplicates/reorders (Comment 21) — implement proper LCS-based diff algorithm - Fix review.log duplicates on idle+delete (Comment 22) — add summaryLogged flag to ensure summary is written once per session --- .opencode/plugins/xpowers-context-gauge.ts | 65 +++++++++++------ .opencode/plugins/xpowers-git-guard.ts | 37 +++++++--- .opencode/plugins/xpowers-lint-gate.ts | 24 ++++--- .opencode/plugins/xpowers-notify.ts | 22 ++++-- .opencode/plugins/xpowers-slow-mode.ts | 82 +++++++++++++++++----- .opencode/plugins/xpowers-task-monitor.ts | 52 +++++++++----- 6 files changed, 203 insertions(+), 79 deletions(-) diff --git a/.opencode/plugins/xpowers-context-gauge.ts b/.opencode/plugins/xpowers-context-gauge.ts index b4b7356b..a9f33311 100644 --- a/.opencode/plugins/xpowers-context-gauge.ts +++ b/.opencode/plugins/xpowers-context-gauge.ts @@ -83,7 +83,14 @@ const loadConfig = async (directory: string): Promise { if (!modelId) return defaultLimit - const normalized = modelId.toLowerCase().replace(/[^a-z0-9.-]/g, "") + // Strip provider prefix (e.g., "openai/gpt-4o" → "gpt-4o") + const withoutProvider = modelId.includes("/") + ? modelId.split("/").pop() ?? modelId + : modelId + + const normalized = withoutProvider.toLowerCase().replace(/[^a-z0-9.-]/g, "") // Try exact match first + if (limits[withoutProvider]) return limits[withoutProvider] if (limits[modelId]) return limits[modelId] // Try normalized exact match if (limits[normalized]) return limits[normalized] - // Try partial match: model ID must contain the key as a prefix segment - // e.g., "gpt-4.1-mini" contains "gpt-4.1" → match - // but "gpt-4" does NOT contain "gpt-4.1" → no match + // Try partial match: prefer longest/specific match first to avoid + // "gpt-4" matching before "gpt-4o" or "gpt-4.1" + const candidates: { key: string; limit: number; len: number }[] = [] for (const [key, limit] of Object.entries(limits)) { const normalizedKey = key.toLowerCase().replace(/[^a-z0-9.-]/g, "") if (!normalizedKey || normalizedKey === "default") continue @@ -163,10 +176,16 @@ const resolveModelLimit = ( normalized.startsWith(normalizedKey + "-") || normalized.startsWith(normalizedKey + ".") ) { - return limit + candidates.push({ key, limit, len: normalizedKey.length }) } } + // Prefer longest match (most specific) + if (candidates.length > 0) { + candidates.sort((a, b) => b.len - a.len) + return candidates[0].limit + } + return defaultLimit } @@ -180,7 +199,7 @@ type GaugeState = { contextLimit: number compactSuggested: boolean createdAt: number - seenMessageIds: Set // deduplicate token counting on streaming updates + messageContents: Map // messageId -> previous content for delta counting } const sessions = new Map() @@ -196,7 +215,7 @@ const getState = (sessionId: string): GaugeState => { contextLimit: DEFAULT_CONFIG.defaultLimit, compactSuggested: false, createdAt: Date.now(), - seenMessageIds: new Set(), + messageContents: new Map(), } sessions.set(sessionId, state) } @@ -238,7 +257,7 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => { contextLimit: config.defaultLimit, compactSuggested: false, createdAt: Date.now(), - seenMessageIds: new Set(), + messageContents: new Map(), }) return } @@ -271,15 +290,6 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => { const message = (event as any).properties?.message if (!message) return - // Deduplicate: don't double-count the same message on streaming updates - const messageId = message.id ?? (event as any).properties?.messageId - if (messageId && state.seenMessageIds.has(String(messageId))) { - return - } - if (messageId) { - state.seenMessageIds.add(String(messageId)) - } - // Try to extract content from message let content = "" if (message.content) { @@ -309,9 +319,20 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => { .join(" ") } - const addedTokens = estimateTokens(content) - state.estimatedTokens += addedTokens - state.messageCount += 1 + // Incremental token counting: only count the delta on streaming updates + const messageId = message.id ?? (event as any).properties?.messageId ?? "" + const prevContent = messageId ? state.messageContents.get(String(messageId)) : undefined + const prevTokens = prevContent !== undefined ? estimateTokens(prevContent) : 0 + const newTokens = estimateTokens(content) + const deltaTokens = Math.max(0, newTokens - prevTokens) + + state.estimatedTokens += deltaTokens + if (prevContent === undefined) { + state.messageCount += 1 // only count new messages, not streaming updates + } + if (messageId) { + state.messageContents.set(String(messageId), content) + } // Try to detect model from message metadata const detectedModel = @@ -363,7 +384,7 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => { 8000, ) - if (!state.compactSuggested && config.suggestCompactAt <= config.dangerThreshold) { + if (!state.compactSuggested && usage >= config.suggestCompactAt) { state.compactSuggested = true // Inject suggestion into session diff --git a/.opencode/plugins/xpowers-git-guard.ts b/.opencode/plugins/xpowers-git-guard.ts index 96ce4a1f..94555ed7 100644 --- a/.opencode/plugins/xpowers-git-guard.ts +++ b/.opencode/plugins/xpowers-git-guard.ts @@ -42,6 +42,7 @@ type SessionState = { commitMade: boolean warnedOnIdle: boolean createdAt: number + pendingCommitStagedFiles: string[] // staged files captured before git commit } const DEFAULT_CONFIG: Required = { @@ -181,12 +182,15 @@ const getGitDiffStat = async ($: any, cwd: string): Promise<{ files: number; ins const lastLine = output.split("\n").filter((l) => l.trim()).pop() ?? "" // Parse: " 5 files changed, 23 insertions(+), 10 deletions(-)" - const match = lastLine.match(/(\d+)\s+files?\s+changed.*?(\d+)\s+insertions?.*?(\d+)\s+deletions?/) - if (match) { + // Note: insertions or deletions may be absent in partial output + const filesMatch = lastLine.match(/(\d+)\s+files?\s+changed/) + if (filesMatch) { + const insertionsMatch = lastLine.match(/(\d+)\s+insertions?/) + const deletionsMatch = lastLine.match(/(\d+)\s+deletions?/) return { - files: parseInt(match[1], 10), - insertions: parseInt(match[2], 10), - deletions: parseInt(match[3], 10), + files: parseInt(filesMatch[1], 10), + insertions: insertionsMatch ? parseInt(insertionsMatch[1], 10) : 0, + deletions: deletionsMatch ? parseInt(deletionsMatch[1], 10) : 0, } } @@ -260,7 +264,7 @@ const sessions = new Map() const getSessionState = (sessionId: string): SessionState => { let state = sessions.get(sessionId) if (!state) { - state = { filesModified: new Set(), filesCommitted: new Set(), commitMade: false, warnedOnIdle: false, createdAt: Date.now() } + state = { filesModified: new Set(), filesCommitted: new Set(), commitMade: false, warnedOnIdle: false, createdAt: Date.now(), pendingCommitStagedFiles: [] } sessions.set(sessionId, state) } return state @@ -285,6 +289,20 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => { } return { + // ── Pre-commit: capture staged files before git commit runs ─────────── + "tool.execute.before": async (input, output) => { + if (input.tool !== "bash") return + const command = String((output.args as any)?.command ?? "") + if (!/git\s+commit/.test(command)) return + + const sessionId = (input as any).sessionID ?? "unknown" + const state = getSessionState(sessionId) + + // Capture staged files BEFORE the commit executes + const status = await getGitStatus(ctx.$, ctx.directory) + state.pendingCommitStagedFiles = [...status.stagedFiles] + }, + // ── Track file modifications and git commits ────────────────────────── "tool.execute.after": async (input, output) => { const sessionId = (input as any).sessionID ?? "unknown" @@ -317,12 +335,12 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => { if (/git\s+commit/.test(command)) { state.commitMade = true - // Try to extract committed files from the command or check git status - const status = await getGitStatus(ctx.$, ctx.directory) - for (const file of status.stagedFiles) { + // Use staged files captured BEFORE the commit (post-commit staged list is empty) + for (const file of state.pendingCommitStagedFiles) { state.filesCommitted.add(file) state.filesModified.delete(file) } + state.pendingCommitStagedFiles = [] await showToast( ctx.client, @@ -348,6 +366,7 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => { commitMade: false, warnedOnIdle: false, createdAt: Date.now(), + pendingCommitStagedFiles: [], }) return } diff --git a/.opencode/plugins/xpowers-lint-gate.ts b/.opencode/plugins/xpowers-lint-gate.ts index 448d70d5..4ce28060 100644 --- a/.opencode/plugins/xpowers-lint-gate.ts +++ b/.opencode/plugins/xpowers-lint-gate.ts @@ -218,10 +218,15 @@ const createShellCheckParser = (): LinterConfig["parseOutput"] => { for (const line of lines) { const match = line.match(/^(.+?):(\d+):(\d+):\s*(\w+):\s*(\w+):\s*(.+)/) if (match) { + const rawSeverity = match[4].toLowerCase() + // ShellCheck emits: error, warning, info, style + const severity: LintIssue["severity"] = + rawSeverity === "error" ? "error" : + rawSeverity === "warning" ? "warning" : "style" issues.push({ line: parseInt(match[2], 10), column: parseInt(match[3], 10), - severity: match[4] as "error" | "warning", + severity, rule: match[5], message: match[6].trim(), }) @@ -237,42 +242,42 @@ const LINTER_REGISTRY: FileLinterMap = { ".ts": { name: "eslint", command: "eslint", - args: ["--format", "compact"], + args: [], fixArgs: ["--fix"], parseOutput: createESLintParser("eslint"), }, ".tsx": { name: "eslint", command: "eslint", - args: ["--format", "compact"], + args: [], fixArgs: ["--fix"], parseOutput: createESLintParser("eslint"), }, ".js": { name: "eslint", command: "eslint", - args: ["--format", "compact"], + args: [], fixArgs: ["--fix"], parseOutput: createESLintParser("eslint"), }, ".jsx": { name: "eslint", command: "eslint", - args: ["--format", "compact"], + args: [], fixArgs: ["--fix"], parseOutput: createESLintParser("eslint"), }, ".mjs": { name: "eslint", command: "eslint", - args: ["--format", "compact"], + args: [], fixArgs: ["--fix"], parseOutput: createESLintParser("eslint"), }, ".cjs": { name: "eslint", command: "eslint", - args: ["--format", "compact"], + args: [], fixArgs: ["--fix"], parseOutput: createESLintParser("eslint"), }, @@ -423,8 +428,11 @@ const runLinter = async ( autoFix: boolean, ): Promise => { try { + // When auto-fixing, use fix-mode args instead of check-mode args. + // Some linters (black, rustfmt) use check-only base args (--check) + // that prevent fixes when combined with fix args. const args = autoFix && linter.fixArgs - ? [...linter.args, ...linter.fixArgs, filePath] + ? [...linter.fixArgs, filePath] : [...linter.args, filePath] // Build command as array for Bun shell to safely pass each arg diff --git a/.opencode/plugins/xpowers-notify.ts b/.opencode/plugins/xpowers-notify.ts index a1879e29..f34a2f2b 100644 --- a/.opencode/plugins/xpowers-notify.ts +++ b/.opencode/plugins/xpowers-notify.ts @@ -38,7 +38,8 @@ const DEFAULT_CONFIG: Required = { onTaskComplete: true, onTaskError: true, onSessionCompact: false, - backends: ["osc777", "osc99", "osascript", "notify-send"], + // Prefer native OS notifications; OSC terminals are fallbacks + backends: ["osascript", "notify-send", "growlnotify", "osc777", "osc99"], durationMs: 4000, titlePrefix: "XPowers", } @@ -228,7 +229,11 @@ const xpowersNotifyPlugin: Plugin = async (ctx) => { const notify = async (title: string, message: string, variant: NotifyVariant) => { const backend = await getBackend() if (backend) { - await sendDesktopNotification(ctx.$, backend, title, message) + const ok = await sendDesktopNotification(ctx.$, backend, title, message) + // If OSC backend failed, invalidate cache so fallback is tried next time + if (!ok && (backend === "osc777" || backend === "osc99")) { + cachedBackend = null + } } await showToast(ctx.client, title, message, variant, config.durationMs) } @@ -236,16 +241,15 @@ const xpowersNotifyPlugin: Plugin = async (ctx) => { return { // When the AI agent finishes responding and goes idle event: async ({ event }) => { - if (!config.onAgentIdle) return - if (event.type === "session.idle") { + if (!config.onAgentIdle) return const title = `${config.titlePrefix}` const message = "Agent finished responding" await notify(title, message, "info") return } - if (config.onSessionCompact && event.type === "session.compacted") { + if (event.type === "session.compacted" && config.onSessionCompact) { const title = `${config.titlePrefix}` const message = "Session compacted" await notify(title, message, "info") @@ -272,11 +276,15 @@ const xpowersNotifyPlugin: Plugin = async (ctx) => { const title = `${config.titlePrefix} · ${agentName}${modelSuffix}` const message = `Task ${statusText}` - if (variant === "error" && config.onTaskError) { - await notify(title, message, variant) + // Error notifications: honor onTaskError config + if (variant === "error") { + if (config.onTaskError) { + await notify(title, message, variant) + } return } + // Non-error completions: honor onTaskComplete config if (config.onTaskComplete) { await notify(title, message, variant) } diff --git a/.opencode/plugins/xpowers-slow-mode.ts b/.opencode/plugins/xpowers-slow-mode.ts index a0a32926..ec1c33e9 100644 --- a/.opencode/plugins/xpowers-slow-mode.ts +++ b/.opencode/plugins/xpowers-slow-mode.ts @@ -118,35 +118,80 @@ const isProtectedPath = (filePath: string, patterns: string[]): boolean => { // ── Diff Computation ──────────────────────────────────────────────────────── +/** + * Compute LCS (Longest Common Subsequence) between two arrays. + * Returns a Set of indices into `a` that are part of the LCS. + */ +const computeLcsIndices = (a: string[], b: string[]): Set => { + const m = a.length + const n = b.length + // Use two rows for space efficiency + let prev = new Array(n + 1).fill(0) + let curr = new Array(n + 1).fill(0) + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) { + curr[j] = prev[j - 1] + 1 + } else { + curr[j] = Math.max(prev[j], curr[j - 1]) + } + } + ;[prev, curr] = [curr, prev] + } + + // Backtrack to find which indices of `a` are in the LCS + const lcsIndices = new Set() + let i = m + let j = n + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + lcsIndices.add(i - 1) + i-- + j-- + } else if (prev[j] > curr[j - 1]) { + i-- + } else { + j-- + } + } + return lcsIndices +} + const computeLineDiff = (original: string, updated: string): { added: number; removed: number; diffLines: string[] } => { const origLines = original.split("\n") const newLines = updated.split("\n") - // Simple LCS-based diff would be ideal; using a simplified approach: - // Track which lines appear in both, then report additions/removals - const origSet = new Set(origLines) - const newSet = new Set(newLines) + const lcsIndices = computeLcsIndices(origLines, newLines) let added = 0 let removed = 0 const diffLines: string[] = [] - // Find removed lines (in original but not in new) - for (const line of origLines) { - if (!newSet.has(line) && line.trim().length > 0) { + // Lines in original but not in LCS = removed + for (let i = 0; i < origLines.length; i++) { + if (!lcsIndices.has(i) && origLines[i].trim().length > 0) { removed++ if (diffLines.length < 20) { - diffLines.push(`- ${line.slice(0, 80)}`) + diffLines.push(`- ${origLines[i].slice(0, 80)}`) } } } - // Find added lines (in new but not in original) - for (const line of newLines) { - if (!origSet.has(line) && line.trim().length > 0) { + // Build a set of LCS lines for quick lookup on the new side + const lcsLines = new Set() + for (let i = 0; i < origLines.length; i++) { + if (lcsIndices.has(i)) lcsLines.add(origLines[i]) + } + + // Lines in new but not in LCS = added + // We use a separate pass to handle duplicates correctly + const newLcsIndices = computeLcsIndices(newLines, origLines) + for (let i = 0; i < newLines.length; i++) { + if (!newLcsIndices.has(i) && newLines[i].trim().length > 0) { added++ if (diffLines.length < 20) { - diffLines.push(`+ ${line.slice(0, 80)}`) + diffLines.push(`+ ${newLines[i].slice(0, 80)}`) } } } @@ -238,6 +283,7 @@ const xpowersSlowModePlugin: Plugin = async (ctx) => { changes: FileChange[] pendingOriginals: Map // filePath -> original content createdAt: number + summaryLogged: boolean // prevent duplicate session summaries in review.log } const sessions = new Map() @@ -245,7 +291,7 @@ const xpowersSlowModePlugin: Plugin = async (ctx) => { const getSessionState = (sessionId: string): SlowModeSession => { let state = sessions.get(sessionId) if (!state) { - state = { changes: [], pendingOriginals: new Map(), createdAt: Date.now() } + state = { changes: [], pendingOriginals: new Map(), createdAt: Date.now(), summaryLogged: false } sessions.set(sessionId, state) } return state @@ -390,8 +436,11 @@ const xpowersSlowModePlugin: Plugin = async (ctx) => { if (event.type === "session.deleted" && sessionId) { const state = sessions.get(sessionId) - if (state) { + if (state && !state.summaryLogged) { await logSessionSummary(logDir, sessionId, state.changes) + state.summaryLogged = true + } + if (state) { sessions.delete(sessionId) } // Cleanup orphaned sessions older than 24 hours @@ -401,7 +450,7 @@ const xpowersSlowModePlugin: Plugin = async (ctx) => { if (event.type === "session.idle") { const state = sessions.get(sessionId) - if (state && state.changes.length > 0) { + if (state && state.changes.length > 0 && !state.summaryLogged) { const files = [...new Set(state.changes.map((c) => c.filePath))] const totalAdded = state.changes.reduce((sum, c) => sum + c.linesAdded, 0) const totalRemoved = state.changes.reduce((sum, c) => sum + c.linesRemoved, 0) @@ -414,8 +463,9 @@ const xpowersSlowModePlugin: Plugin = async (ctx) => { 6000, ) - // Write summary to log + // Write summary to log (only once per session) await logSessionSummary(logDir, sessionId, state.changes) + state.summaryLogged = true } } }, diff --git a/.opencode/plugins/xpowers-task-monitor.ts b/.opencode/plugins/xpowers-task-monitor.ts index 5cd1ed17..4fa3cfcc 100644 --- a/.opencode/plugins/xpowers-task-monitor.ts +++ b/.opencode/plugins/xpowers-task-monitor.ts @@ -100,17 +100,31 @@ const parseTasks = (output: string): ParsedTask[] => { if (trimmed.startsWith("Status:")) continue if (trimmed.includes("no active blockers")) continue - // Match: [indicator] [id] [status] P[priority] [title] + // Try agent-style format first: [indicator] [id] [status] P[priority] [title] // ○ hyper-5ct ● P1 [epic] Project: Rename repository to xpowers - // Supports priorities P0-P99 (multi-digit) - const match = trimmed.match( + const agentMatch = trimmed.match( /^[○◐●✓❄]\s+([a-z]+-[a-z0-9]+)\s+.*?P(\d+)\s+(.+)/, ) - if (match) { + if (agentMatch) { tasks.push({ - id: match[1], - priority: parseInt(match[2], 10), - title: match[3].trim(), + id: agentMatch[1], + priority: parseInt(agentMatch[2], 10), + title: agentMatch[3].trim(), + }) + continue + } + + // Fallback: simple format without priority — [indicator] [ID] [title] + // ○ ENG_CORE-123 Some Task + // ○ ENG-456 In Progress Task + const simpleMatch = trimmed.match( + /^[○◐●✓❄]\s+([A-Z_]+-\d+)\s+(.+)/, + ) + if (simpleMatch) { + tasks.push({ + id: simpleMatch[1], + priority: 2, // default medium priority when not specified + title: simpleMatch[2].trim(), }) } } @@ -191,7 +205,10 @@ const xpowersTaskMonitorPlugin: Plugin = async (ctx) => { let isShuttingDown = false const notifyNewTasks = async (tasks: ParsedTask[]) => { - const newTasks = tasks.filter((t) => !seenTasks.has(t.id)) + // When trackSeenTasks is disabled, treat all tasks as new + const newTasks = config.trackSeenTasks + ? tasks.filter((t) => !seenTasks.has(t.id)) + : tasks if (newTasks.length === 0) return @@ -217,12 +234,13 @@ const xpowersTaskMonitorPlugin: Plugin = async (ctx) => { ) } - // Record newly seen tasks - for (const task of newTasks) { - seenTasks.set(task.id, { ...task, firstSeenAt: Date.now() }) + // Record newly seen tasks (only when tracking is enabled) + if (config.trackSeenTasks) { + for (const task of newTasks) { + seenTasks.set(task.id, { ...task, firstSeenAt: Date.now() }) + } + await saveSeenTasks(cachePath, seenTasks) } - - await saveSeenTasks(cachePath, seenTasks) } const notifyTaskCount = async (count: number, changed: boolean) => { @@ -332,10 +350,10 @@ const xpowersTaskMonitorPlugin: Plugin = async (ctx) => { return } - // Cleanup timer when session ends - if (event.type === "session.deleted") { - stopPolling() - } + // Note: pollTimer is per-plugin, not per-session. + // In long-lived OpenCode processes, new sessions after deletion + // should still receive task notifications, so we do NOT stop polling here. + // The timer is only cleaned up when the plugin itself is destroyed. }, // Custom tool: AI can query task status