Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b7dbc25
feat(ci): add AI review gate
EZotoff Mar 10, 2026
aa9e92e
fix(ingest): bridge subagent questions into project status
EZotoff Mar 10, 2026
d000477
test(server): cover question status mapping in multi-project snapshots
EZotoff Mar 10, 2026
c2760fc
fix(ci): harden AI review gate follow-up
EZotoff Mar 10, 2026
dc3dfd5
feat(ingest): add error stale detection with message timestamps
EZotoff Mar 26, 2026
d9393fa
perf(server): parallelize git ops and pre-filter sessions
EZotoff Mar 26, 2026
9994336
feat(ui): add running_script status, tool badges, and worktree badge …
EZotoff Mar 26, 2026
c175c29
refactor(ui): update app layout and settings for new features
EZotoff Mar 26, 2026
3f1311a
perf(ingest): reuse sqlite handles for dashboard reads
EZotoff Apr 10, 2026
b36b1a8
perf(server): cache sqlite-backed project aggregation
EZotoff Apr 10, 2026
73bd417
feat(server): expose source management and telegram status
EZotoff Apr 10, 2026
0c1c3da
feat(server): add telegram notification service bootstrap
EZotoff Apr 10, 2026
d20a584
fix(ui): refine question status indicator styling
EZotoff Apr 10, 2026
6fd31d9
docs(env): document telegram notification variables
EZotoff Apr 10, 2026
4cd731a
perf(ingest): reuse sqlite handles for dashboard reads
EZotoff Apr 10, 2026
37fb7c0
perf(server): cache sqlite-backed project aggregation
EZotoff Apr 10, 2026
0381c1f
feat(server): expose source management and telegram status
EZotoff Apr 10, 2026
b0f653b
feat(server): add telegram notification service bootstrap
EZotoff Apr 10, 2026
c9b9523
fix(ui): refine question status indicator styling
EZotoff Apr 10, 2026
0e2a6cc
docs(env): document telegram notification variables
EZotoff Apr 10, 2026
e7976a9
docs(readme): add Ko-fi badge
EZotoff Apr 10, 2026
899871b
fix(ingest): use last-terminal-event logic for error status instead o…
EZotoff Apr 14, 2026
680fd49
Merge feature/telegram-and-sqlite-cache: preserve terminal error hand…
EZotoff Apr 15, 2026
5703f85
Merge feat/ai-review-gate: preserve non-blocking review gate semantics
EZotoff Apr 15, 2026
46697c8
Merge fix/subagent-question-status: preserve question bridge coverage
EZotoff Apr 15, 2026
54f9e61
fix(ui): refine question strip pulse and timeout labels
EZotoff Apr 15, 2026
384a370
fix(ui): restore compact strip layout and reduce chrome load
EZotoff Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ OMO_PULSE_API_PORT=4301
# Set to "true" in CI/CD environments to prevent Playwright from reusing existing servers
# In local development, leave unset (defaults to false)
CI=

# Telegram Notifications (optional)
# Get token from @BotFather, chat ID from @userinfobot or the getUpdates API
# When both are set, omo-pulse pushes a pinned status message to the chat
# and sends alert notifications for question/error/plan_complete transitions.
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
[![Runtime: Bun](https://img.shields.io/badge/Runtime-Bun-%23f9f1e1?logo=bun)](https://bun.sh)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue?logo=typescript)](https://www.typescriptlang.org/)
[![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-lightgrey)](https://github.com/sponsors/EZotoff)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/ezotoff)

![Dashboard — multi-project view with session activity and token usage](docs/screenshots/details-collapsed.png)

Expand Down
17 changes: 5 additions & 12 deletions src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,6 @@ vi.mock("../ingest/sources-registry", () => ({
getSourceById: vi.fn(() => null),
}))

vi.mock("../server/multi-project", () => ({
createMultiProjectService: vi.fn(() => ({
getMultiProjectPayload: vi.fn(async (): Promise<DashboardMultiProjectPayload> => ({
projects: [],
serverNowMs: Date.now(),
pollIntervalMs: 2000,
})),
})),
}))

vi.mock("../ingest/session", () => ({
getStorageRoots: vi.fn(() => ({
session: "/tmp/session",
Expand Down Expand Up @@ -63,7 +53,6 @@ vi.mock("../ingest/sqlite-derive", () => ({
// Import AFTER mocking
// ---------------------------------------------------------------------------
import { createApi } from "../server/api"
import { createMultiProjectService } from "../server/multi-project"
import type { ProjectSnapshot } from "../types"

// ---------------------------------------------------------------------------
Expand All @@ -83,6 +72,8 @@ function makeProjectSnapshot(overrides: Partial<ProjectSnapshot> = {}): ProjectS
sessionId: "ses_abc",
status: "idle",
},
sessions: [],
aggregateStatus: "idle",
planProgress: {
name: "plan-1",
completed: 3,
Expand All @@ -93,6 +84,7 @@ function makeProjectSnapshot(overrides: Partial<ProjectSnapshot> = {}): ProjectS
planStale: false,
planComplete: false,
},
unintiatedPlans: [],
timeSeries: {
windowMs: 300000,
bucketMs: 2000,
Expand Down Expand Up @@ -127,12 +119,13 @@ describe("API routes", () => {
serverNowMs: Date.now(),
pollIntervalMs: 2000,
})),
invalidate: vi.fn(),
}
vi.mocked(createMultiProjectService).mockReturnValue(mockService)

app = createApi({
storageRoot: "/tmp/test-storage",
storageBackend: { kind: "sqlite", dataDir: "/tmp", sqlitePath: "/tmp/test.db" },
multiProjectService: mockService,
version: "1.0.0-test",
})
})
Expand Down
115 changes: 114 additions & 1 deletion src/__tests__/question-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"

import { afterEach, describe, expect, it } from "vitest"
import { afterEach, describe, expect, it, vi } from "vitest"

import { deriveBackgroundTasks } from "../ingest/background-tasks"
import { getMainSessionView, type OpenCodeStorageRoots, type SessionMetadata, type StoredMessageMeta, type StoredToolPart } from "../ingest/session"
Expand Down Expand Up @@ -32,6 +32,8 @@ function makeTempStorage(): OpenCodeStorageRoots {
}

afterEach(() => {
vi.resetModules()
vi.doUnmock("../ingest/storage-backend")
while (tempDirs.length > 0) {
const dir = tempDirs.pop()
if (dir) fs.rmSync(dir, { recursive: true, force: true })
Expand Down Expand Up @@ -163,4 +165,115 @@ describe("background question bridge", () => {
expect(view.status).toBe("question")
expect(view.currentTool).toBe("mcp_question")
})

it("surfaces question for SQLite background tasks and main-session fallback", async () => {
vi.doMock("../ingest/storage-backend", () => {
const mainSessionMeta: SessionMetadata = {
id: "ses-main",
projectID: "proj-1",
directory: "/tmp/project",
time: { created: 900_000, updated: 999_000 },
}
const childSessionMeta: SessionMetadata = {
id: "ses-child",
projectID: "proj-1",
directory: "/tmp/project",
parentID: "ses-main",
title: "Ask the user (@atlas subagent)",
time: { created: 999_100, updated: 999_900 },
}
const mainMeta: StoredMessageMeta = {
id: "msg-main",
sessionID: "ses-main",
role: "assistant",
time: { created: 999_000, completed: 999_100 },
agent: "build",
}
const childMeta: StoredMessageMeta = {
id: "msg-child",
sessionID: "ses-child",
role: "assistant",
time: { created: 999_900 },
agent: "atlas",
}
const mainTaskPart: PersistedToolPart = {
id: "part-main",
sessionID: "ses-main",
messageID: "msg-main",
type: "tool",
callID: "call-main",
tool: "background_task",
state: {
status: "completed",
input: {
description: "Ask the user",
run_in_background: true,
subagent_type: "atlas",
},
metadata: { sessionId: "ses-child" },
time: { start: 999_050 },
},
}
const childQuestionPart: StoredToolPart = {
id: "part-child",
sessionID: "ses-child",
messageID: "msg-child",
type: "tool",
callID: "call-child",
tool: "mcp_question",
state: {
status: "pending",
input: {},
},
}

return {
readMainSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta] })),
readAllSessionMetasSqlite: vi.fn(() => ({ ok: true as const, rows: [mainSessionMeta, childSessionMeta] })),
readSessionExistsSqlite: vi.fn(() => ({ ok: true as const, rows: [{ id: "ses-child" }] })),
readTodosSqlite: vi.fn(() => ({ ok: true as const, rows: [] })),
readRecentMessageMetasSqlite: vi.fn(({ sessionId }: { sessionId: string }) => {
if (sessionId === "ses-main") return { ok: true as const, rows: [mainMeta] }
if (sessionId === "ses-child") return { ok: true as const, rows: [childMeta] }
return { ok: true as const, rows: [] }
}),
readToolPartsForMessagesSqlite: vi.fn(({ messageIds }: { messageIds: string[] }) => {
const rows: StoredToolPart[] = []
if (messageIds.includes("msg-main")) rows.push(mainTaskPart)
if (messageIds.includes("msg-child")) rows.push(childQuestionPart)
return { ok: true as const, rows }
}),
}
})

const { deriveBackgroundTasksSqlite, getMainSessionViewSqlite } = await import("../ingest/sqlite-derive")

const tasksResult = deriveBackgroundTasksSqlite({
sqlitePath: "/tmp/opencode.db",
mainSessionId: "ses-main",
nowMs: 1_000_000,
})

expect(tasksResult.ok).toBe(true)
if (!tasksResult.ok) throw new Error("expected sqlite background tasks")
expect(tasksResult.value[0]?.status).toBe("question")
expect(tasksResult.value[0]?.lastTool).toBe("mcp_question")

const viewResult = getMainSessionViewSqlite({
sqlitePath: "/tmp/opencode.db",
sessionId: "ses-main",
sessionMeta: {
id: "ses-main",
projectID: "proj-1",
directory: "/tmp/project",
time: { created: 900_000, updated: 999_000 },
},
nowMs: 1_000_000,
})

expect(viewResult.ok).toBe(true)
if (!viewResult.ok) throw new Error("expected sqlite main session view")
expect(viewResult.value.status).toBe("question")
expect(viewResult.value.currentTool).toBe("mcp_question")
})
})
34 changes: 17 additions & 17 deletions src/__tests__/session-inclusion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@ type SessionRow = {

type ActivePartRow = {
tool: string
status: string
status?: string
}

type ErrorCountRow = {
cnt: number
type TerminalPartRow = {
status: string
time_created: number
}

type AssistantMessageRow = {
role: string
time_completed?: number
time_completed: number | null
}

type QueryRows = SessionRow[] | ActivePartRow[] | ErrorCountRow[] | AssistantMessageRow[]
type QueryRows = SessionRow[] | ActivePartRow[] | TerminalPartRow[] | AssistantMessageRow[]

type MockStatement = {
all: (...params: unknown[]) => QueryRows
Expand All @@ -44,7 +44,7 @@ type MockDatabase = {
type MockDbConfig = {
sessionRows?: SessionRow[]
activePartsBySession?: Record<string, ActivePartRow[]>
errorCountsBySession?: Record<string, number>
terminalPartsBySession?: Record<string, TerminalPartRow[]>
assistantMessagesBySession?: Record<string, AssistantMessageRow[]>
throwOnQuery?: boolean
}
Expand All @@ -64,15 +64,15 @@ function createMockDb(config: MockDbConfig = {}): MockDatabase {
return config.sessionRows ?? []
}

if (sql.includes("state_status = 'pending' OR state_status = 'running'")) {
if (sql.includes("'pending', 'running'")) {
return sessionId ? (config.activePartsBySession?.[sessionId] ?? []) : []
}

if (sql.includes("state_status = 'error'")) {
return [{ cnt: sessionId ? (config.errorCountsBySession?.[sessionId] ?? 0) : 0 }]
if (sql.includes("'error', 'completed'")) {
return sessionId ? (config.terminalPartsBySession?.[sessionId] ?? []) : []
}

if (sql.includes("FROM message")) {
if (sql.includes("json_extract(data, '$.role') = 'assistant'")) {
return sessionId ? (config.assistantMessagesBySession?.[sessionId] ?? []) : []
}

Expand Down Expand Up @@ -273,7 +273,7 @@ describe("findIncludedSessionsSqlite", () => {
expect(result.map((session) => session.id)).toEqual(["stale-question"])
})

it("keeps stale error sessions included beyond the normal idle window", () => {
it("excludes stale error sessions once error and activity are both stale", () => {
const now = Date.now()
const result = runFindIncludedSessionsSqlite(
createMockDb({
Expand All @@ -293,15 +293,15 @@ describe("findIncludedSessionsSqlite", () => {
time_updated: now - 120000,
},
],
errorCountsBySession: {
"stale-error": 1,
terminalPartsBySession: {
"stale-error": [{ status: "error", time_created: now - 120000 }],
},
}),
"/home/user/project",
60000,
)

expect(result.map((session) => session.id)).toEqual(["stale-error"])
expect(result.map((session) => session.id)).toEqual([])
})

it("does not treat generic mc_* tools as question status", () => {
Expand Down Expand Up @@ -511,8 +511,8 @@ describe("findIncludedSessionsSqlite", () => {
activePartsBySession: {
"question-session": [{ tool: "mcp_question", status: "pending" }],
},
errorCountsBySession: {
"error-session": 1,
terminalPartsBySession: {
"error-session": [{ status: "error", time_created: now - 30000 }],
},
}),
"/home/user/project",
Expand Down
14 changes: 14 additions & 0 deletions src/ingest/activity-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { TASK_TOOL_NAMES } from "./tool-names"

export const ACTIVE_STALE_MS = 10 * 60_000
export const ACTIVE_BUSY_WINDOW_MS = 60_000
export const ERROR_STALE_MS = 60_000 // Errors become stale after 1 minute
export const BACKGROUND_RUNNING_WINDOW_MS = 15_000
export const BACKGROUND_QUEUE_STALE_MS = 15 * 60_000

Expand All @@ -22,3 +23,16 @@ export function resolveLastUpdatedTime(primary: number | null, fallback: number
export function shouldSuppressStaleToolActivity(toolName: string, hasFreshActivity: boolean): boolean {
return !hasFreshActivity && TASK_TOOL_NAMES.has(toolName)
}

export function getTerminalErrorMessageCreatedAt<T>(opts: {
orderedMessages: readonly T[]
getCreatedAt: (message: T) => number | null
hasErrorPart: (message: T) => boolean
}): number | null {
for (const message of opts.orderedMessages) {
const createdAt = opts.getCreatedAt(message)
if (typeof createdAt !== "number") continue
return opts.hasErrorPart(message) ? createdAt : null
}
return null
}
Loading
Loading