import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { randomUUID } from "node:crypto"; import { getAgentPath } from "../paths.ts"; import type { CreateQueueItemInput, PowerlineQueueItem, QueueAliasMap, QueueContext, QueueIntent, QueueStatus, QueueSummary, QueueTarget, } from "./types.ts"; import { ACTIVE_QUEUE_STATUSES } from "./types.ts"; const STORE_DIR = "powerline-footer"; const INBOX_FILE = "inbox.jsonl"; const ALIASES_FILE = "projects.json"; 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 : null; } function normalizeStatus(value: unknown): QueueStatus | null { return value === "queued" || value === "blocked" || value === "delivering" || value === "sent" || value === "failed" ? value : null; } 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); 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 } : {}), }; } 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 } { return { inboxPath: getAgentPath(STORE_DIR, INBOX_FILE), aliasesPath: getAgentPath(STORE_DIR, ALIASES_FILE), }; } export function createQueueItem(input: CreateQueueItemInput): PowerlineQueueItem { const now = input.now ?? Date.now(); const sourceSessionId = input.source.sessionId?.trim(); 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", }; } export class PowerlineQueueStore { private readonly inboxPath: string; private readonly aliasesPath: string; constructor(inboxPath: string = getQueueStorePaths().inboxPath, aliasesPath: string = getQueueStorePaths().aliasesPath) { this.inboxPath = inboxPath; this.aliasesPath = aliasesPath; } 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) => ( item.status === "queued" && (intent ? item.intent === intent : true) )); } summarize(context: QueueContext, compacting: boolean): QueueSummary { const queueItems = this.activeItems(context); const blockedItems = queueItems.filter((item) => item.status === "blocked" || item.status === "failed"); const leading = [...blockedItems, ...queueItems][0] ?? null; return { queueCount: queueItems.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[]): void { mkdirSync(dirname(this.inboxPath), { recursive: true }); const activeOrRecent = items .filter((item) => item.status !== "sent" || Date.now() - item.updatedAt < 24 * 60 * 60 * 1000) .map((item) => JSON.stringify(item)) .join("\n"); this.writeAtomic(this.inboxPath, activeOrRecent ? `${activeOrRecent}\n` : ""); } 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 formatQueueDeliveryText(item: PowerlineQueueItem): string { return item.text; } 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; }