diff --git a/src/dev-dashboard/ui/vite-middleware.ts b/src/dev-dashboard/ui/vite-middleware.ts index d7e45f4cee..08e882d825 100644 --- a/src/dev-dashboard/ui/vite-middleware.ts +++ b/src/dev-dashboard/ui/vite-middleware.ts @@ -29,14 +29,14 @@ import { listVault, readNote } from "@app/dev-dashboard/lib/obsidian/reader"; import { renderSharePage } from "@app/dev-dashboard/lib/obsidian/share-template"; import { createQaStream, todayLogFile } from "@app/dev-dashboard/lib/qa-sse"; import { configureRetention, getCachedPulse, getSeries, startPulsePolling } from "@app/dev-dashboard/lib/system/poller"; -import { getAudioLibrary } from "@app/utils/audio/library"; -import { resolveSoundBuffer } from "@app/utils/audio/runner.server"; import { addTodo, completeTodo, deleteTodo, listTodos } from "@app/dev-dashboard/lib/todos/service"; import { killTtyd, listTtyd, renameTtyd, spawnTtyd } from "@app/dev-dashboard/lib/ttyd/manager"; import { fetchWeather } from "@app/dev-dashboard/lib/weather/client"; import logger from "@app/logger"; import { defaultDbPath } from "@app/question/commands/log"; import { openReadModel, queryEntries } from "@app/question/lib/read-model"; +import { getAudioLibrary } from "@app/utils/audio/library"; +import { resolveSoundBuffer } from "@app/utils/audio/runner.server"; import { SafeJSON } from "@app/utils/json"; import type { Connect } from "vite"; diff --git a/src/question/commands/config.ts b/src/question/commands/config.ts index 19bafd19a1..c10671ff93 100644 --- a/src/question/commands/config.ts +++ b/src/question/commands/config.ts @@ -16,6 +16,7 @@ export function registerConfigCommand(program: Command): void { .option("--sound [spec]", "synth: | bundled: | custom: | off") .option("--sound-volume ", "0..1", (v) => Number.parseFloat(v)) .option("--notify ", "on|off") + .option("--obsidian ", "on|off") .option("--obsidian-vault ", "set the Obsidian vault override") .option("--list-sounds", "list every available sound (bundled + synth) and exit") .action( @@ -23,6 +24,7 @@ export function registerConfigCommand(program: Command): void { sound?: string | boolean; soundVolume?: number; notify?: string; + obsidian?: string; obsidianVault?: string; listSounds?: boolean; }) => { @@ -53,10 +55,24 @@ export function registerConfigCommand(program: Command): void { next = saveConfig({ soundVolume: Math.max(0, Math.min(1, o.soundVolume)) }); } - if (o.notify === "on" || o.notify === "off") { + if (o.notify !== undefined) { + if (o.notify !== "on" && o.notify !== "off") { + process.stderr.write(`error: --notify expects on|off, got '${o.notify}'\n`); + process.exit(1); + } + next = saveConfig({ sinks: { ...next.sinks, notify: o.notify === "on" } }); } + if (o.obsidian !== undefined) { + if (o.obsidian !== "on" && o.obsidian !== "off") { + process.stderr.write(`error: --obsidian expects on|off, got '${o.obsidian}'\n`); + process.exit(1); + } + + next = saveConfig({ sinks: { ...next.sinks, obsidian: o.obsidian === "on" } }); + } + if (o.obsidianVault) { setVaultRoot(o.obsidianVault); } diff --git a/src/question/commands/log.ts b/src/question/commands/log.ts index b129c94658..d1416e71f2 100644 --- a/src/question/commands/log.ts +++ b/src/question/commands/log.ts @@ -2,6 +2,8 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { SafeJSON } from "@app/utils/json"; import type { Command } from "commander"; +import pc from "picocolors"; +import { formatQaEntry } from "../lib/format"; import { openReadModel, type QueryOpts, queryEntries } from "../lib/read-model"; export function defaultDbPath(): string { @@ -13,17 +15,10 @@ export function renderDigest(opts: QueryOpts & { dbPath: string }): string { try { const rows = queryEntries(db, opts); if (rows.length === 0) { - return "No questions recorded."; + return pc.dim("No questions recorded."); } - return rows - .map((r) => { - const when = new Date(r.ts).toISOString().slice(0, 16).replace("T", " "); - const head = `${when} ${r.project} · ${r.branch ?? "-"} [${r.tag}]`; - const preview = r.answerMd.split("\n").slice(0, 3).join("\n"); - return `${head}\n❯ ${r.question}\n${preview}\n`; - }) - .join("\n"); + return rows.map(formatQaEntry).join("\n"); } finally { db.close(); } diff --git a/src/question/commands/tail.ts b/src/question/commands/tail.ts index 60bfb80b97..869e8f57d4 100644 --- a/src/question/commands/tail.ts +++ b/src/question/commands/tail.ts @@ -1,19 +1,33 @@ import { FileTailer } from "@app/utils/fs/file-tailer"; import type { Command } from "commander"; +import pc from "picocolors"; +import { formatQaEntry } from "../lib/format"; import { logFilePathFor } from "../lib/log-store"; +import { openReadModel, queryEntries } from "../lib/read-model"; import type { QaEntry } from "../lib/types"; +import { defaultDbPath } from "./log"; + +function watchToday(opts: { lines?: number }): void { + const backlog = opts.lines ?? 10; + + // Backlog: last N entries oldest→newest (chronological, like `tail -f`), + // rendered identically to `tools question log` via the shared formatter. + if (backlog > 0) { + const db = openReadModel(defaultDbPath()); + try { + const rows = queryEntries(db, { limit: backlog }); + if (rows.length > 0) { + process.stdout.write(`${[...rows].reverse().map(formatQaEntry).join("\n")}\n`); + } + } finally { + db.close(); + } + } -function watchToday(): void { const file = logFilePathFor({ ts: Date.now() }); - process.stdout.write(`tailing ${file} (Ctrl-C to stop)\n`); + process.stdout.write(pc.dim(`\n— live: tailing ${file} (Ctrl-C to stop) —\n`)); const t = new FileTailer(file, { - onLine: (e) => { - const when = new Date(e.ts).toISOString().slice(11, 16); - const preview = e.answerMd.split("\n").slice(0, 3).join("\n"); - process.stdout.write( - `\n${when} ${e.project} · ${e.branch ?? "-"} [${e.tag}]\n❯ ${e.question}\n${preview}\n` - ); - }, + onLine: (e) => process.stdout.write(`\n${formatQaEntry(e)}`), }); t.start(); process.on("SIGINT", () => { @@ -23,5 +37,10 @@ function watchToday(): void { } export function registerTailCommand(program: Command): void { - program.command("tail").alias("answers").description("Live feed of Q→A as they are recorded").action(watchToday); + program + .command("tail") + .alias("answers") + .description("Live feed of Q→A as they are recorded (with last-N backlog)") + .option("-n, --lines ", "show the last N entries before tailing (0 = none)", (v) => Number.parseInt(v, 10)) + .action((o: { lines?: number }) => watchToday(o)); } diff --git a/src/question/lib/format.ts b/src/question/lib/format.ts new file mode 100644 index 0000000000..8d705c0995 --- /dev/null +++ b/src/question/lib/format.ts @@ -0,0 +1,28 @@ +import pc from "picocolors"; +import type { QaEntry } from "./types"; + +// picocolors (the codebase convention over chalk) auto-detects stdout: full +// color on a TTY, no-ops when piped/redirected — colorful in a terminal, +// clean ANSI-free for `| tools json`. +const TAG_TINT: Record string> = { + question: (s) => pc.bold(pc.blue(s)), + action: (s) => pc.bold(pc.yellow(s)), + directive: (s) => pc.bold(pc.green(s)), +}; + +type FormattableEntry = Pick; + +/** + * Single source of truth for one Q→A entry's terminal rendering. Used by both + * `tools question log` (newest-first digest) and `tools question tail` (live + * feed) so they are visually identical per entry. + */ +export function formatQaEntry(e: FormattableEntry): string { + const when = new Date(e.ts).toISOString().slice(0, 16).replace("T", " "); + const tint = TAG_TINT[e.tag] ?? ((s: string) => pc.bold(pc.gray(s))); + const head = `${pc.dim(when)} ${pc.bold(pc.cyan(e.project))} ${pc.dim("·")} ${pc.magenta(e.branch ?? "-")} ${tint(`[${e.tag}]`)}`; + const preview = pc.yellow(e.answerMd.split("\n").slice(0, 3).join("\n")); + const sid = e.sessionId && e.sessionId !== "unknown" ? e.sessionId.slice(0, 8) : null; + const resume = sid ? pc.dim(` ↩ ${sid} · tools claude resume ${sid}\n`) : ""; + return `${head}\n${pc.green("❯")} ${pc.bold(e.question)}\n${preview}\n${resume}`; +} diff --git a/src/question/lib/sinks/notification.ts b/src/question/lib/sinks/notification.ts index 5edc601935..6e480c87bc 100644 --- a/src/question/lib/sinks/notification.ts +++ b/src/question/lib/sinks/notification.ts @@ -4,13 +4,18 @@ import { registerSink, type Sink } from "./registry-exports"; export function formatNotification(e: QaEntry): { title: string; message: string } { return { - title: `${e.project} · ${e.tag}`, + // Same header shape as `tools question log`/`tail`: project · branch [tag]. + title: `${e.project} · ${e.branch ?? "-"} [${e.tag}]`, message: `❯ ${e.question}\n\n${e.answerMd}`, }; } export const notificationSink: Sink = { name: "notification", + // Telegram = cold `await import(@app/telegram-bot)` + HTTP; the 2s default + // fan-out budget gets truncated by the CLI's process.exit(0) before the + // send completes (system banner is fast, telegram isn't). Give it room. + timeoutMs: 9000, isEnabled: (c) => c.sinks.notify, emit: async (entry) => { const { title, message } = formatNotification(entry); diff --git a/src/question/lib/sinks/registry.ts b/src/question/lib/sinks/registry.ts index dbcc4098a2..5135cc6876 100644 --- a/src/question/lib/sinks/registry.ts +++ b/src/question/lib/sinks/registry.ts @@ -38,7 +38,7 @@ export async function runFanOut( }); return Promise.all( enabled.map(async (s): Promise => { - const t = timeout(timeoutMs); + const t = timeout(s.timeoutMs ?? timeoutMs); try { await Promise.race([Promise.resolve(s.emit(entry, config)), t.promise]); return { name: s.name, ok: true }; diff --git a/src/question/lib/sinks/types.ts b/src/question/lib/sinks/types.ts index 00b27eed66..120198f698 100644 --- a/src/question/lib/sinks/types.ts +++ b/src/question/lib/sinks/types.ts @@ -15,6 +15,10 @@ export interface Sink { name: string; isEnabled(config: QuestionConfig): boolean; emit(entry: QaEntry, config: QuestionConfig): Promise | void; + /** Optional per-sink fan-out budget (ms). Network sinks (Telegram does a + * cold dynamic import + HTTP) need more than the 2s default or they get + * cut off by process.exit(0) before delivery. */ + timeoutMs?: number; } // SinkResult is defined once, canonically, in ../types (Task 2 — RecordResult.sinks uses it). diff --git a/src/utils/agent-runtime.ts b/src/utils/agent-runtime.ts index 7427148de6..a5968c5cbe 100644 --- a/src/utils/agent-runtime.ts +++ b/src/utils/agent-runtime.ts @@ -53,8 +53,7 @@ export function getAgentRuntimeContext( project: detectCurrentProject() ?? basename(repoRoot), repoRoot, cwd, - isWorktree: - gitDir != null && gitCommonDir != null && resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir), + isWorktree: gitDir != null && gitCommonDir != null && resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir), worktreePath: null, branch: gitSync(["rev-parse", "--abbrev-ref", "HEAD"], cwd), commitSha: gitSync(["rev-parse", "--short", "HEAD"], cwd), diff --git a/src/utils/audio/library.ts b/src/utils/audio/library.ts index 1b93561d9a..18bdcf8b5d 100644 --- a/src/utils/audio/library.ts +++ b/src/utils/audio/library.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { type BundledSound, BUNDLED_SOUNDS } from "./assets/manifest"; +import { BUNDLED_SOUNDS, type BundledSound } from "./assets/manifest"; import { DING_PRESETS } from "./ding-presets"; import type { SoundChoice } from "./runner.server"; @@ -57,9 +57,7 @@ export function getAudioLibrary(): AudioLibrary { return { bundled, synth, default: def }; } -export type ParseSoundResult = - | { ok: true; sound?: SoundChoice; enabled: boolean } - | { ok: false; error: string }; +export type ParseSoundResult = { ok: true; sound?: SoundChoice; enabled: boolean } | { ok: false; error: string }; /** * Parse a `--sound` / dashboard spec into a validated `SoundChoice`. @@ -112,9 +110,7 @@ export function parseSoundSpec(spec: string): ParseSoundResult { /** Human-readable list of every available sound (for `--list-sounds` / errors). */ export function formatAudioLibrary(): string { const lib = getAudioLibrary(); - const bundled = lib.bundled - .map((e) => ` ${e.id}${e.isDefault ? " (default)" : ""} — ${e.label}`) - .join("\n"); + const bundled = lib.bundled.map((e) => ` ${e.id}${e.isDefault ? " (default)" : ""} — ${e.label}`).join("\n"); const synth = lib.synth.map((e) => ` ${e.id} — ${e.label}`).join("\n"); return `Available sounds:\n\nBundled (Kenney CC0):\n${bundled}\n\nSynth presets:\n${synth}\n\nAlso valid: custom:/abs/path.wav | off`; }