diff --git a/.opencode/plugins/xpowers-context-gauge.ts b/.opencode/plugins/xpowers-context-gauge.ts index 83a7298c..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 match + // Try normalized exact match if (limits[normalized]) return limits[normalized] - // Try partial match on model family + // 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 + + // 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 + 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 } @@ -173,6 +198,8 @@ type GaugeState = { modelId: string | null contextLimit: number compactSuggested: boolean + createdAt: number + messageContents: Map // messageId -> previous content for delta counting } const sessions = new Map() @@ -187,12 +214,23 @@ const getState = (sessionId: string): GaugeState => { modelId: null, contextLimit: DEFAULT_CONFIG.defaultLimit, compactSuggested: false, + createdAt: Date.now(), + messageContents: new Map(), } 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 +256,8 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => { modelId: null, contextLimit: config.defaultLimit, compactSuggested: false, + createdAt: Date.now(), + messageContents: new Map(), }) return } @@ -241,6 +281,8 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => { if (event.type === "session.deleted" && sessionId) { sessions.delete(sessionId) + // Cleanup orphaned sessions older than 24 hours + cleanupOldGaugeSessions() return } @@ -277,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 = @@ -331,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 e9360c86..94555ed7 100644 --- a/.opencode/plugins/xpowers-git-guard.ts +++ b/.opencode/plugins/xpowers-git-guard.ts @@ -41,6 +41,8 @@ type SessionState = { filesCommitted: Set commitMade: boolean warnedOnIdle: boolean + createdAt: number + pendingCommitStagedFiles: string[] // staged files captured before git commit } const DEFAULT_CONFIG: Required = { @@ -126,8 +128,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 +163,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 @@ -163,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, } } @@ -180,9 +202,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" } } @@ -232,12 +264,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(), pendingCommitStagedFiles: [] } 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) => { @@ -248,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" @@ -280,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, @@ -310,6 +365,8 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => { filesCommitted: new Set(), commitMade: false, warnedOnIdle: false, + createdAt: Date.now(), + pendingCommitStagedFiles: [], }) return } @@ -317,8 +374,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 +396,8 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => { } } sessions.delete(sessionId) + // Cleanup orphaned sessions older than 24 hours + cleanupOldGitGuardSessions() return } diff --git a/.opencode/plugins/xpowers-lint-gate.ts b/.opencode/plugins/xpowers-lint-gate.ts index 5b2b8cda..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,13 +428,19 @@ 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] - 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 +535,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 +584,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..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", } @@ -96,7 +97,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 +112,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 +143,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 } @@ -216,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) } @@ -224,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") @@ -260,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 b46659da..ec1c33e9 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) @@ -114,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)}`) } } } @@ -230,20 +279,33 @@ const xpowersSlowModePlugin: Plugin = async (ctx) => { } // Per-session state tracking - const sessions = new Map // filePath -> original content - }>() + createdAt: number + summaryLogged: boolean // prevent duplicate session summaries in review.log + } + + const sessions = new Map() - const getSessionState = (sessionId: string) => { + 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(), summaryLogged: false } 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 { @@ -374,16 +436,21 @@ 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 + cleanupOldSessions() return } 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) @@ -396,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 376bcc7e..4fa3cfcc 100644 --- a/.opencode/plugins/xpowers-task-monitor.ts +++ b/.opencode/plugins/xpowers-task-monitor.ts @@ -100,16 +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 - const match = trimmed.match( - /^[○◐●✓❄]\s+([a-z]+-[a-z0-9]+)\s+.*?P(\d)\s+(.+)/, + 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(), }) } } @@ -187,9 +202,13 @@ 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)) + // 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 @@ -215,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) => { @@ -242,7 +262,7 @@ const xpowersTaskMonitorPlugin: Plugin = async (ctx) => { } const doPoll = async () => { - if (isPolling) return + if (isPolling || isShuttingDown) return isPolling = true try { @@ -265,6 +285,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) @@ -322,11 +350,10 @@ const xpowersTaskMonitorPlugin: Plugin = async (ctx) => { return } - // Cleanup timer when session ends - if (event.type === "session.deleted" && pollTimer) { - clearInterval(pollTimer) - pollTimer = null - } + // 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