import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { dirname, resolve } from "node:path"; import { randomUUID } from "node:crypto"; import { getAgentPath } from "../src/paths/agent-dirs.ts"; import type { CreateQueueItemInput, IdeaReviewStatus, PowerlineQueueItem, QueueAliasMap, QueueContext, QueueIntent, QueueStatus, QueueSummary, QueueTarget, } from "./types.ts"; import { ACTIVE_QUEUE_STATUSES, IDEA_REVIEW_STATUSES } from "./types.ts"; const STORE_DIR = "powerline-footer"; const INBOX_FILE = "inbox.jsonl"; const ARCHIVE_FILE = "inbox.archive.jsonl"; const ALIASES_FILE = "projects.json"; const DEFAULT_SENT_RETENTION_MS = 24 * 60 * 60 * 1000; const LOCK_RETRY_MS = 25; const LOCK_TIMEOUT_MS = 2000; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function normalizeCwd(cwd: string): string { return resolve(cwd); } function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function normalizeTarget(value: unknown): QueueTarget | null { if (!isRecord(value)) return null; if (value.kind === "current-session") return { kind: "current-session" }; if (value.kind === "global") return { kind: "global" }; if ( value.kind === "project" && typeof value.cwd === "string" && value.cwd.trim() ) { const alias = normalizeOptionalString(value.alias); return alias ? { kind: "project", cwd: normalizeCwd(value.cwd), alias } : { kind: "project", cwd: normalizeCwd(value.cwd) }; } return null; } function normalizeIntent(value: unknown): QueueIntent | null { return value === "steer" || value === "follow-up" || value === "post-compact" || value === "idea" ? value : null; } function normalizeStatus(value: unknown): QueueStatus | null { return value === "queued" || value === "blocked" || value === "delivering" || value === "sent" || value === "failed" ? value : null; } function normalizeReviewStatus(value: unknown): IdeaReviewStatus | undefined { return IDEA_REVIEW_STATUSES.includes(value as IdeaReviewStatus) ? (value as IdeaReviewStatus) : undefined; } const MAX_IDEA_TAGS = 8; const MAX_TAG_CHARS = 32; export function normalizeIdeaTags(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; const seen = new Set(); const tags: string[] = []; for (const entry of value) { if (typeof entry !== "string") continue; const tag = entry.trim().slice(0, MAX_TAG_CHARS); if (!tag || seen.has(tag)) continue; seen.add(tag); tags.push(tag); if (tags.length >= MAX_IDEA_TAGS) break; } return tags.length > 0 ? tags : undefined; } function normalizeItem(value: unknown): PowerlineQueueItem | null { if (!isRecord(value)) return null; if (typeof value.id !== "string" || !value.id.trim()) return null; if (typeof value.text !== "string" || !value.text.trim()) return null; if (typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; if (typeof value.updatedAt !== "number" || !Number.isFinite(value.updatedAt)) return null; if ( !isRecord(value.source) || typeof value.source.cwd !== "string" || !value.source.cwd.trim() ) return null; const target = normalizeTarget(value.target); const intent = normalizeIntent(value.intent); const status = normalizeStatus(value.status); if (!target || !intent || !status) return null; const sessionId = normalizeOptionalString(value.source.sessionId); const error = normalizeOptionalString(value.error); const reviewStatus = intent === "idea" ? normalizeReviewStatus(value.reviewStatus) : undefined; const tags = intent === "idea" ? normalizeIdeaTags(value.tags) : undefined; return { id: value.id.trim(), text: value.text, createdAt: value.createdAt, updatedAt: value.updatedAt, source: sessionId ? { cwd: normalizeCwd(value.source.cwd), sessionId } : { cwd: normalizeCwd(value.source.cwd) }, target, intent, status, ...(error ? { error } : {}), ...(reviewStatus ? { reviewStatus } : {}), ...(tags ? { tags } : {}), }; } function readJsonObject(path: string): Record { if (!existsSync(path)) return {}; const parsed = JSON.parse(readFileSync(path, "utf-8")); return isRecord(parsed) ? parsed : {}; } function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } export function getQueueStorePaths(): { inboxPath: string; aliasesPath: string; archivePath: string; } { return { inboxPath: getAgentPath(STORE_DIR, INBOX_FILE), aliasesPath: getAgentPath(STORE_DIR, ALIASES_FILE), archivePath: getAgentPath(STORE_DIR, ARCHIVE_FILE), }; } export function createQueueItem( input: CreateQueueItemInput, ): PowerlineQueueItem { const now = input.now ?? Date.now(); const sourceSessionId = input.source.sessionId?.trim(); const reviewStatus = input.intent === "idea" ? (input.reviewStatus ?? "idea") : undefined; const tags = input.intent === "idea" ? normalizeIdeaTags(input.tags) : undefined; return { id: randomUUID().slice(0, 8), text: input.text, createdAt: now, updatedAt: now, source: sourceSessionId ? { cwd: normalizeCwd(input.source.cwd), sessionId: sourceSessionId } : { cwd: normalizeCwd(input.source.cwd) }, target: normalizeTarget(input.target) ?? input.target, intent: input.intent, status: input.status ?? "queued", ...(reviewStatus ? { reviewStatus } : {}), ...(tags ? { tags } : {}), }; } export class PowerlineQueueStore { private readonly inboxPath: string; private readonly aliasesPath: string; private readonly archivePath: string; /** How long completed (sent) items stay in the inbox before pruning/archiving. */ private sentRetentionMs: number = DEFAULT_SENT_RETENTION_MS; constructor( inboxPath: string = getQueueStorePaths().inboxPath, aliasesPath: string = getQueueStorePaths().aliasesPath, archivePath: string = getQueueStorePaths().archivePath, ) { this.inboxPath = inboxPath; this.aliasesPath = aliasesPath; this.archivePath = archivePath; } /** Configure the retention window for completed items (clamped to >= 1 hour). */ setSentRetentionMs(ms: number): void { this.sentRetentionMs = Math.max(60 * 60 * 1000, Number.isFinite(ms) ? ms : DEFAULT_SENT_RETENTION_MS); } list(): PowerlineQueueItem[] { if (!existsSync(this.inboxPath)) return []; const lines = readFileSync(this.inboxPath, "utf-8").split("\n"); const items: PowerlineQueueItem[] = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { const item = normalizeItem(JSON.parse(trimmed)); if (item) items.push(item); } catch { // Ignore malformed internal lines rather than breaking the footer during startup. } } return items.sort((a, b) => a.createdAt - b.createdAt); } add(input: CreateQueueItemInput): PowerlineQueueItem { const item = createQueueItem(input); return this.withStoreLock(() => { this.writeItems([...this.list(), item]); return item; }); } get(idPrefix: string): PowerlineQueueItem | null { const normalized = idPrefix.trim(); if (!normalized) return null; const matches = this.list().filter( (item) => item.id === normalized || item.id.startsWith(normalized), ); return matches.length === 1 ? matches[0] : null; } update( id: string, updates: Partial>, ): PowerlineQueueItem | null { return this.withStoreLock(() => { let updated: PowerlineQueueItem | null = null; const next = this.list().map((item) => { if (item.id !== id) return item; updated = { ...item, ...updates, updatedAt: updates.updatedAt ?? Date.now(), }; return updated; }); if (updated) this.writeItems(next); return updated; }); } clear(id: string): PowerlineQueueItem | null { return this.update(id, { status: "sent", error: undefined }); } activeItems(context: QueueContext): PowerlineQueueItem[] { return this.list().filter((item) => isActiveForContext(item, context)); } queuedDeliveryItems( context: QueueContext, intent?: QueueIntent, ): PowerlineQueueItem[] { return this.activeItems(context).filter((item) => { if (item.status !== "queued") return false; if (item.intent === "idea") return false; return intent ? item.intent === intent : true; }); } summarize(context: QueueContext, compacting: boolean): QueueSummary { const active = this.activeItems(context); const queueItems = active.filter((item) => item.intent !== "idea"); const ideaItems = active.filter((item) => item.intent === "idea"); const blockedItems = active.filter( (item) => item.status === "blocked" || item.status === "failed", ); const leading = [...blockedItems, ...queueItems, ...ideaItems][0] ?? null; return { queueCount: queueItems.length, ideaCount: ideaItems.length, blockedCount: blockedItems.length, compacting, leadingText: leading?.text ?? null, leadingIntent: leading?.intent ?? null, leadingStatus: leading?.status ?? null, }; } readAliases(): QueueAliasMap { const parsed = readJsonObject(this.aliasesPath); const aliases: QueueAliasMap = {}; for (const [alias, cwd] of Object.entries(parsed)) { if ( /^[a-zA-Z0-9_-]+$/.test(alias) && typeof cwd === "string" && cwd.trim() ) { aliases[alias] = normalizeCwd(cwd); } } return aliases; } setAlias(alias: string, cwd: string): QueueAliasMap { const normalizedAlias = alias.trim(); if (!/^[a-zA-Z0-9_-]+$/.test(normalizedAlias)) { throw new Error( "Alias must contain only letters, numbers, dashes, or underscores", ); } return this.withStoreLock(() => { const aliases = { ...this.readAliases(), [normalizedAlias]: normalizeCwd(cwd), }; this.writeJson(this.aliasesPath, aliases); return aliases; }); } resolveAlias(alias: string): string | null { return this.readAliases()[alias] ?? null; } private withStoreLock(fn: () => T): T { mkdirSync(dirname(this.inboxPath), { recursive: true }); const lockPath = `${this.inboxPath}.lock`; const startedAt = Date.now(); while (true) { try { mkdirSync(lockPath); break; } catch (error) { const code = error && typeof error === "object" && "code" in error ? error.code : undefined; if (code !== "EEXIST") throw error; if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) { throw new Error("Timed out waiting for Powerline queue store lock"); } sleepSync(LOCK_RETRY_MS); } } try { return fn(); } finally { rmSync(lockPath, { recursive: true, force: true }); } } private writeItems( items: readonly PowerlineQueueItem[], now: number = Date.now(), ): void { mkdirSync(dirname(this.inboxPath), { recursive: true }); const retentionCutoff = now - this.sentRetentionMs; const activeOrRecent = items .filter( (item) => item.status !== "sent" || item.updatedAt >= retentionCutoff, ) .map((item) => JSON.stringify(item)) .join("\n"); this.writeAtomic( this.inboxPath, activeOrRecent ? `${activeOrRecent}\n` : "", ); } /** * Move completed (sent) items older than the retention window out of the * inbox into the archive file, keeping the inbox lean. Returns how many * items were archived and how many sent items remain in the inbox. * * `archiveSentItems` and `writeItems` share one `now` snapshot so a sent * item counted as remaining cannot be pruned on the same write. */ archiveSentItems( olderThanMs: number = this.sentRetentionMs, now: number = Date.now(), ): { archived: number; remainingSent: number; } { return this.withStoreLock(() => { const items = this.list(); // writeItems prunes sent items older than sentRetentionMs on every write. // If the requested archive window were wider than that, items in the gap // would be silently dropped (neither archived nor kept) while still being // reported as "remaining". Clamp the cutoff so anything past the inbox // retention window is archived rather than lost. const effectiveOlderThanMs = Math.min(olderThanMs, this.sentRetentionMs); const cutoff = now - effectiveOlderThanMs; const archived: PowerlineQueueItem[] = []; const kept: PowerlineQueueItem[] = []; let remainingSent = 0; for (const item of items) { if (item.status === "sent" && item.updatedAt < cutoff) { archived.push(item); } else { kept.push(item); if (item.status === "sent") remainingSent += 1; } } if (archived.length > 0) { // ponytail: commit the inbox (now without the archived items) before // the archive. If the archive write fails, the items stay in the inbox // and are re-archived next run instead of being duplicated. this.writeItems(kept, now); mkdirSync(dirname(this.archivePath), { recursive: true }); const existing = existsSync(this.archivePath) ? readFileSync(this.archivePath, "utf-8") : ""; const lines = archived.map((item) => JSON.stringify(item)).join("\n"); this.writeAtomic( this.archivePath, `${existing.trimEnd() ? `${existing.trimEnd()}\n` : ""}${lines}\n`, ); } return { archived: archived.length, remainingSent }; }); } private writeJson(path: string, value: unknown): void { mkdirSync(dirname(path), { recursive: true }); this.writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`); } private writeAtomic(path: string, content: string): void { const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; writeFileSync(tempPath, content, "utf-8"); renameSync(tempPath, path); } } export function currentQueueContext( cwd: string, sessionId?: string, ): QueueContext { return sessionId?.trim() ? { cwd: normalizeCwd(cwd), sessionId: sessionId.trim() } : { cwd: normalizeCwd(cwd) }; } export function isActiveForContext( item: PowerlineQueueItem, context: QueueContext, ): boolean { if (!ACTIVE_QUEUE_STATUSES.has(item.status)) return false; const currentCwd = normalizeCwd(context.cwd); if (item.target.kind === "global") return true; if (item.target.kind === "project") return normalizeCwd(item.target.cwd) === currentCwd; if (item.source.sessionId) return context.sessionId === item.source.sessionId; return item.source.cwd === currentCwd; } export function targetForIdea( rawTarget: string | null, store: PowerlineQueueStore, cwd: string, ): QueueTarget { if (!rawTarget) return { kind: "project", cwd: normalizeCwd(cwd) }; if (rawTarget === "current") return { kind: "current-session" }; if (rawTarget === "global") return { kind: "global" }; const aliasCwd = store.resolveAlias(rawTarget); if (!aliasCwd) { throw new Error( `Unknown project alias @${rawTarget}. Use /queue alias ${rawTarget} first.`, ); } return { kind: "project", cwd: aliasCwd, alias: rawTarget }; } export function parseTargetPrefix(text: string): { target: string | null; text: string; } { const trimmed = text.trim(); const match = /^@([a-zA-Z0-9_-]+)(?:\s+|$)/.exec(trimmed); if (!match) return { target: null, text: trimmed }; return { target: match[1], text: trimmed.slice(match[0].length).trim() }; } export function parseSigilIdeaCapture( text: string, sigil: string | false, ): { target: string | null; text: string } | null { if (sigil === false) return null; const normalizedSigil = sigil.trim(); if (!normalizedSigil) return null; const trimmed = text.trim(); if (!trimmed.startsWith(normalizedSigil)) return null; const afterSigil = trimmed.slice(normalizedSigil.length); if (!/^\s/.test(afterSigil)) return null; const parsed = parseTargetPrefix(afterSigil.trim()); return parsed.text ? parsed : null; } export function formatQueueDeliveryText(item: PowerlineQueueItem): string { if (item.intent !== "idea") return item.text; return `[powerline idea ${item.id}, captured ${new Date(item.createdAt).toISOString()} from ${item.source.cwd}]\n${item.text}`; } export function formatIdeaIssuePrompt(item: PowerlineQueueItem): string { const target = item.target.kind === "project" ? `project ${item.target.alias ? `@${item.target.alias} ` : ""}${item.target.cwd}` : item.target.kind; return `Please process this saved Powerline idea as a GitHub issue candidate.\n\n${formatQueueDeliveryText(item)}\n\nIssue filing rules:\n- If subagents are available, spawn one low-budget issue-filing lane for this idea; otherwise do the same checks directly.\n- First identify the target repository from the idea target (${target}), source cwd (${item.source.cwd}), and current session context.\n- If the target repository is unclear or is not owned/controlled by the user, ask before filing anything.\n- If the target repository is clear and owned/controlled by the user, dedupe against existing open issues first.\n- If a matching open issue already exists, report it and do not create another issue.\n- If no matching issue exists, create one self-contained GitHub issue in that repository with a clear title, context, acceptance criteria, and the Powerline idea provenance above.\n- Use explicit repository targeting for GitHub commands and do not change source files for this handoff.`; } export function parseCompactQueuedPrompt(text: string): string | null { const trimmed = text.trim(); const match = /^\/compact\s+(.+)$/.exec(trimmed); if (!match) return null; const prompt = match[1].trim(); return prompt ? prompt : null; }