import { ClaudeBridge } from "../slack/bridge"; import { syncIssues } from "./sync"; import * as path from "path"; const _log = console.log.bind(console); const _err = console.error.bind(console); const pdt = () => new Date().toLocaleString("en-US", { timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }); console.log = (...args: any[]) => _log(`[${pdt()}]`, ...args); console.error = (...args: any[]) => _err(`[${pdt()}]`, ...args); const API_KEY = process.env.LINEAR_API_KEY; if (!API_KEY) { console.error("Missing LINEAR_API_KEY"); process.exit(1); } const API_URL = "https://api.linear.app/graphql"; const POLL_INTERVAL_MS = 60_000; let aceUserId: string; async function gql(query: string, variables?: Record): Promise { const res = await fetch(API_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: API_KEY!, }, body: JSON.stringify({ query, variables }), }); if (!res.ok) { const text = await res.text(); throw new Error(`Linear API ${res.status}: ${text}`); } const json = await res.json(); if (json.errors?.length) { throw new Error(`Linear GraphQL: ${json.errors.map((e: any) => e.message).join(", ")}`); } return json.data; } const RECENT_COMMENTS_QUERY = ` query RecentComments($after: DateTimeOrDuration!) { comments( filter: { createdAt: { gt: $after } } first: 50 orderBy: createdAt ) { nodes { id body createdAt user { id name email } issue { id identifier title url description state { name type } assignee { id name } labels { nodes { name } } project { name } } } } } `; const ISSUE_COMMENTS_QUERY = ` query IssueComments($issueId: String!) { issue(id: $issueId) { id identifier title url description state { name type } assignee { id name } labels { nodes { name } } project { name } comments(first: 50) { nodes { id body createdAt user { id name } } } } } `; function formatForLinear(text: string): string { // Linear renders Markdown where single newlines are collapsed into the // same paragraph. Claude's responses use single newlines for visual // line breaks. Convert single \n between non-blank lines to \n\n so // each line renders as its own paragraph, but preserve existing double // newlines, blank lines inside code blocks, and list structure. return text.replace(/([^\n])\n(?=\S)/g, "$1\n\n"); } async function postComment(issueId: string, body: string): Promise { await gql( `mutation PostComment($input: CommentCreateInput!) { commentCreate(input: $input) { success } }`, { input: { issueId, body } } ); } async function fetchIssueWithComments(issueId: string): Promise { const data = await gql(ISSUE_COMMENTS_QUERY, { issueId }); return data.issue; } function formatIssueContext(issue: any): string { const labels = issue.labels?.nodes?.map((l: any) => l.name).join(", ") || "none"; const comments = (issue.comments?.nodes || []) .sort((a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) .map((c: any) => `[${c.user?.name || "Unknown"} @ ${c.createdAt}]\n${c.body}`) .join("\n\n---\n\n"); return [ `**Issue:** ${issue.identifier} — ${issue.title}`, `**Status:** ${issue.state?.name || "unknown"}`, `**Assignee:** ${issue.assignee?.name || "unassigned"}`, `**Labels:** ${labels}`, `**URL:** ${issue.url}`, "", `**Description:**`, issue.description || "(no description)", "", `**Comments (${issue.comments?.nodes?.length || 0}):**`, comments || "(no comments)", ].join("\n"); } interface Comment { id: string; body: string; createdAt: string; user: { id: string; name: string; email?: string }; issue: { id: string; identifier: string; title: string; url: string; description?: string; state?: { name: string; type: string }; assignee?: { id: string; name: string }; labels?: { nodes: { name: string }[] }; project?: { name: string }; }; } const processedCommentIds = new Set(); const threadBridges = new Map(); const IDLE_TIMEOUT_MS = 15 * 60 * 1000; const threadIdleTimers = new Map>(); function resetIdleTimer(threadKey: string): void { const existing = threadIdleTimers.get(threadKey); if (existing) clearTimeout(existing); threadIdleTimers.set(threadKey, setTimeout(() => { const bridge = threadBridges.get(threadKey); if (bridge) { console.log(`[linear] idle timeout: cleaning up thread ${threadKey}`); bridge.kill(); threadBridges.delete(threadKey); } threadIdleTimers.delete(threadKey); }, IDLE_TIMEOUT_MS)); } let lastPollTime: string; async function handleComment(comment: Comment): Promise { const { user, issue } = comment; const actorName = user.name || "Someone"; console.log(`[linear] handling comment on ${issue.identifier} "${issue.title}" by ${actorName}`); const fullIssue = await fetchIssueWithComments(issue.id); const allComments = (fullIssue.comments?.nodes || []) as { id: string; createdAt: string; user?: { id: string } }[]; const aceRepliedAfter = allComments.some( (c) => c.user?.id === aceUserId && new Date(c.createdAt) > new Date(comment.createdAt) ); if (aceRepliedAfter) { console.log(`[linear] already replied to ${issue.identifier} after this comment, skipping`); return; } const context = formatIssueContext(fullIssue); const threadKey = issue.id; let bridge = threadBridges.get(threadKey); const isNewSession = !bridge; if (!bridge) { bridge = new ClaudeBridge(); bridge.model = "sonnet"; bridge.channel = "linear"; bridge.setStateFile(path.join(__dirname, `../logs/sessions/.linear-thread-${issue.id}`)); const PROJECT_TO_DOMAIN: Record = { junwonhome: "junwonhome", junwoncompany: "junwoncompany", palacefund: "palacefund", palaceapp: "palaceapp", palacelab: "palacelab", }; const projectName = issue.project?.name?.toLowerCase(); if (projectName && PROJECT_TO_DOMAIN[projectName]) { bridge.domain = PROJECT_TO_DOMAIN[projectName]; } } let prompt: string; if (isNewSession) { prompt = `You are Ace, responding on a Linear issue. Here is the full issue context: ${context} --- ${actorName} just commented: "${comment.body}" Use tools to take actions when asked (update issue status, etc.). Do NOT use tools to post comments — your text response will be posted as a comment automatically. No emojis.`; } else { prompt = `New comment from ${actorName} on ${issue.identifier}: "${comment.body}" Use tools to take actions when asked (update issue status, etc.). Do NOT use tools to post comments — your text response will be posted as a comment automatically. No emojis.`; } try { const response = await bridge.send(prompt); threadBridges.set(threadKey, bridge); resetIdleTimer(threadKey); if (response?.trim()) { await postComment(issue.id, formatForLinear(response.trim())); console.log(`[linear] posted reply on ${issue.identifier} (${response.length} chars)`); } } catch (err) { console.error(`[linear] bridge error on ${issue.identifier}:`, err); bridge.kill(); threadBridges.delete(threadKey); } } async function poll(): Promise { try { const data = await gql(RECENT_COMMENTS_QUERY, { after: lastPollTime }); const comments: Comment[] = data.comments?.nodes || []; const newComments = comments.filter((c) => { if (processedCommentIds.has(c.id)) return false; if (c.user.id === aceUserId) return false; return true; }); if (newComments.length > 0) { console.log(`[linear] ${newComments.length} new comment(s) from others`); } for (const comment of newComments) { processedCommentIds.add(comment.id); try { await handleComment(comment); } catch (err) { console.error(`[linear] failed to handle comment ${comment.id}:`, err); } } lastPollTime = new Date().toISOString(); } catch (err) { console.error(`[linear] poll error:`, err); } } async function run() { console.log("[linear] daemon starting"); const data = await gql("{ viewer { id name email } }"); aceUserId = data.viewer.id; console.log(`[linear] authenticated as: ${data.viewer.name} (${data.viewer.email}) [${aceUserId}]`); lastPollTime = new Date(Date.now() - 5 * 60 * 1000).toISOString(); await poll(); setInterval(poll, POLL_INTERVAL_MS); console.log(`[linear] polling every ${POLL_INTERVAL_MS / 1000}s`); // Issue sync: run once on startup, then every hour const SYNC_INTERVAL_MS = 60 * 60 * 1000; const runSync = async () => { try { await syncIssues(API_KEY!); } catch (err) { console.error("[linear] sync error:", err); } }; await runSync(); setInterval(runSync, SYNC_INTERVAL_MS); console.log(`[linear] issue sync every ${SYNC_INTERVAL_MS / 60000} min`); } run().catch((err) => { console.error("[linear] fatal:", err); process.exit(1); });