vibe-pomo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ import Database from 'better-sqlite3'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { mkdirSync } from 'node:fs'
5
+
6
+ const DB_PATH = join(homedir(), '.claude', 'pomodoro.db')
7
+
8
+ function openDb() {
9
+ mkdirSync(join(homedir(), '.claude'), { recursive: true })
10
+ const db = new Database(DB_PATH)
11
+ db.pragma('journal_mode = WAL')
12
+
13
+ db.exec(`
14
+ CREATE TABLE IF NOT EXISTS sessions (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ project_hash TEXT NOT NULL,
17
+ project_path TEXT NOT NULL,
18
+ started_at INTEGER NOT NULL,
19
+ ended_at INTEGER,
20
+ planned_ms INTEGER NOT NULL,
21
+ actual_ms INTEGER,
22
+ cycles INTEGER DEFAULT 1,
23
+ status TEXT NOT NULL,
24
+ task TEXT,
25
+ agent_summary TEXT, -- from .claude/pomodoro-summary.md
26
+ user_activity TEXT -- what the user did during this session (entered at session end)
27
+ );
28
+
29
+ -- Migrate existing tables: add new columns if they don't exist yet
30
+ -- (SQLite doesn't support IF NOT EXISTS for columns, so we use a safe approach)
31
+
32
+
33
+ CREATE TABLE IF NOT EXISTS notifications (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ session_id INTEGER REFERENCES sessions(id),
36
+ received_at INTEGER NOT NULL,
37
+ released_at INTEGER,
38
+ notification_json TEXT NOT NULL
39
+ );
40
+
41
+ CREATE VIEW IF NOT EXISTS project_stats AS
42
+ SELECT
43
+ project_path,
44
+ COUNT(*) AS total_sessions,
45
+ SUM(cycles) AS total_cycles,
46
+ SUM(actual_ms) / 60000 AS total_minutes,
47
+ MAX(started_at) AS last_active
48
+ FROM sessions
49
+ WHERE status != 'broken'
50
+ GROUP BY project_path;
51
+ `)
52
+
53
+ // Safe column migrations for existing databases
54
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name)
55
+ if (!cols.includes('agent_summary')) {
56
+ db.exec(`ALTER TABLE sessions ADD COLUMN agent_summary TEXT`)
57
+ }
58
+ if (!cols.includes('user_activity')) {
59
+ db.exec(`ALTER TABLE sessions ADD COLUMN user_activity TEXT`)
60
+ }
61
+
62
+ return db
63
+ }
64
+
65
+ let _db = null
66
+ function getDb() {
67
+ if (!_db) _db = openDb()
68
+ return _db
69
+ }
70
+
71
+ export function startSession({ projectHash, projectPath, plannedMs, task }) {
72
+ const db = getDb()
73
+ const result = db.prepare(`
74
+ INSERT INTO sessions (project_hash, project_path, started_at, planned_ms, task, status)
75
+ VALUES (?, ?, ?, ?, ?, 'running')
76
+ `).run(projectHash, projectPath, Date.now(), plannedMs, task ?? null)
77
+ return result.lastInsertRowid
78
+ }
79
+
80
+ export function endSession(sessionId, { actualMs, status, agentSummary }) {
81
+ const db = getDb()
82
+ db.prepare(`
83
+ UPDATE sessions
84
+ SET ended_at = ?, actual_ms = ?, status = ?, agent_summary = ?
85
+ WHERE id = ?
86
+ `).run(Date.now(), actualMs, status, agentSummary ?? null, sessionId)
87
+ }
88
+
89
+ export function updateSessionActivity(sessionId, { userActivity }) {
90
+ const db = getDb()
91
+ db.prepare(`UPDATE sessions SET user_activity = ? WHERE id = ?`)
92
+ .run(userActivity ?? null, sessionId)
93
+ }
94
+
95
+ export function saveNotification(sessionId, { notificationJson, receivedAt, releasedAt }) {
96
+ const db = getDb()
97
+ db.prepare(`
98
+ INSERT INTO notifications (session_id, received_at, released_at, notification_json)
99
+ VALUES (?, ?, ?, ?)
100
+ `).run(sessionId, receivedAt, releasedAt ?? null, notificationJson)
101
+ }
102
+
103
+ /** Return stats for Dashboard TUI */
104
+ export function getStats() {
105
+ const db = getDb()
106
+ const projects = db.prepare(`
107
+ SELECT project_path, total_sessions, total_cycles, total_minutes, last_active
108
+ FROM project_stats ORDER BY last_active DESC LIMIT 10
109
+ `).all()
110
+
111
+ const recent = db.prepare(`
112
+ SELECT project_path, task, actual_ms, status, started_at, agent_summary, user_activity
113
+ FROM sessions ORDER BY started_at DESC LIMIT 8
114
+ `).all()
115
+
116
+ return { projects, recent }
117
+ }
118
+
119
+ /** Return all sessions for a project, for detailed review */
120
+ export function getProjectSessions(projectPath) {
121
+ const db = getDb()
122
+ return db.prepare(`
123
+ SELECT id, task, started_at, ended_at, actual_ms, status, agent_summary, user_activity
124
+ FROM sessions
125
+ WHERE project_path = ?
126
+ ORDER BY started_at DESC
127
+ `).all(projectPath)
128
+ }
129
+
130
+ /** Print stats to stdout as a formatted table */
131
+ export function printStats() {
132
+ const db = getDb()
133
+
134
+ const projects = db.prepare(`
135
+ SELECT
136
+ project_path,
137
+ total_sessions,
138
+ total_cycles,
139
+ total_minutes,
140
+ last_active
141
+ FROM project_stats
142
+ ORDER BY last_active DESC
143
+ `).all()
144
+
145
+ if (projects.length === 0) {
146
+ console.log('No completed sessions yet.')
147
+ return
148
+ }
149
+
150
+ console.log('\nProject Statistics\n' + '─'.repeat(72))
151
+ console.log(
152
+ 'Project'.padEnd(36) +
153
+ 'Sessions'.padStart(9) +
154
+ 'Cycles'.padStart(8) +
155
+ 'Minutes'.padStart(9) +
156
+ ' Last Active'
157
+ )
158
+ console.log('─'.repeat(72))
159
+
160
+ for (const row of projects) {
161
+ const path = row.project_path.replace(homedir(), '~')
162
+ const truncated = path.length > 35 ? '…' + path.slice(-34) : path
163
+ const date = new Date(row.last_active).toLocaleDateString()
164
+ console.log(
165
+ truncated.padEnd(36) +
166
+ String(row.total_sessions).padStart(9) +
167
+ String(row.total_cycles).padStart(8) +
168
+ String(row.total_minutes ?? 0).padStart(9) +
169
+ ` ${date}`
170
+ )
171
+ }
172
+
173
+ // Recent sessions
174
+ const recent = db.prepare(`
175
+ SELECT project_path, task, actual_ms, cycles, status, started_at
176
+ FROM sessions
177
+ ORDER BY started_at DESC
178
+ LIMIT 10
179
+ `).all()
180
+
181
+ console.log('\nRecent Sessions\n' + '─'.repeat(72))
182
+ console.log(
183
+ 'Task'.padEnd(38) +
184
+ 'Min'.padStart(5) +
185
+ 'Cyc'.padStart(5) +
186
+ ' Status'.padEnd(14) +
187
+ ' Date'
188
+ )
189
+ console.log('─'.repeat(72))
190
+
191
+ for (const s of recent) {
192
+ const taskLabel = (s.task ?? '(no task)').slice(0, 37).padEnd(38)
193
+ const mins = s.actual_ms ? String(Math.round(s.actual_ms / 60000)).padStart(5) : ' —'
194
+ const cyc = String(s.cycles).padStart(5)
195
+ const status = s.status.padEnd(12)
196
+ const date = new Date(s.started_at).toLocaleDateString()
197
+ console.log(`${taskLabel}${mins}${cyc} ${status} ${date}`)
198
+ if (s.agent_summary) {
199
+ const line = s.agent_summary.split('\n')[0].slice(0, 68)
200
+ console.log(` 🤖 ${line}`)
201
+ }
202
+ if (s.user_activity) {
203
+ const line = s.user_activity.slice(0, 68)
204
+ console.log(` 👤 ${line}`)
205
+ }
206
+ }
207
+
208
+ console.log()
209
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Global Pomodoro Daemon
3
+ * Manages multiple concurrent sessions across all Claude Code projects.
4
+ *
5
+ * Started by `pomodoro daemon` (foreground, inside Dashboard TUI)
6
+ * or spawned detached if no dashboard is running.
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+ import { getSocketPath, writeLock, removeLock, projectHash } from '../shared/lockfile.mjs'
10
+ import { EVT, MSG, STATE, DECISION } from '../shared/protocol.mjs'
11
+ import { IpcServer } from './ipc.mjs'
12
+ import { Session } from './session.mjs'
13
+ import { readFileSync, existsSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { startSession, endSession, saveNotification, updateSessionActivity } from './db.mjs'
16
+
17
+ const ipc = new IpcServer()
18
+
19
+ /** sessionId → Session */
20
+ const sessions = new Map()
21
+
22
+ /** sessionId → DB row id */
23
+ const dbIds = new Map()
24
+
25
+ // ── IPC Handlers ─────────────────────────────────────────────────────────────
26
+
27
+ ipc.on(MSG.QUERY, () => ({
28
+ sessions: [...sessions.values()].map((s) => s.snapshot()),
29
+ }))
30
+
31
+ ipc.on(MSG.SESSION_QUERY, (msg) => {
32
+ const session = sessions.get(msg.sessionId)
33
+ if (!session) return { error: 'session not found' }
34
+ return session.snapshot()
35
+ })
36
+
37
+ ipc.on(MSG.SESSION_CREATE, (msg) => {
38
+ const { projectDir, task, plannedMs, decisionStrategy = DECISION.WAIT } = msg
39
+ const hash = projectHash(projectDir ?? process.cwd())
40
+
41
+ const session = new Session({
42
+ projectDir: projectDir ?? process.cwd(),
43
+ projectHash: hash,
44
+ task: task ?? '',
45
+ plannedMs: plannedMs ?? 25 * 60 * 1000,
46
+ decisionStrategy,
47
+ })
48
+
49
+ sessions.set(session.id, session)
50
+ wireSessionEvents(session)
51
+
52
+ const dbId = startSession({
53
+ projectHash: hash,
54
+ projectPath: projectDir ?? process.cwd(),
55
+ plannedMs: session.plannedMs,
56
+ task: session.task,
57
+ })
58
+ dbIds.set(session.id, dbId)
59
+
60
+ // Broadcast snapshot to dashboard subscribers
61
+ ipc.broadcast({ type: EVT.SNAPSHOT, sessions: [...sessions.values()].map((s) => s.snapshot()) })
62
+
63
+ return { sessionId: session.id, ...session.snapshot() }
64
+ })
65
+
66
+ ipc.on(MSG.SESSION_END, (msg) => {
67
+ const session = sessions.get(msg.sessionId)
68
+ if (!session) return { error: 'session not found' }
69
+ session.end()
70
+ return { ok: true }
71
+ })
72
+
73
+ ipc.on(MSG.SESSION_BREAK, (msg) => {
74
+ const session = sessions.get(msg.sessionId)
75
+ if (!session) return { error: 'session not found' }
76
+ session.break()
77
+ return { ok: true }
78
+ })
79
+
80
+ ipc.on(MSG.SESSION_UPDATE_ACTIVITY, (msg) => {
81
+ const dbId = dbIds.get(msg.sessionId)
82
+ if (!dbId) return { error: 'session not found' }
83
+ updateSessionActivity(dbId, { userActivity: msg.userActivity })
84
+ return { ok: true }
85
+ })
86
+
87
+ ipc.on(MSG.QUEUE_NOTIFICATION, (msg) => {
88
+ const session = sessions.get(msg.sessionId)
89
+ if (!session) return { ok: false, error: 'session not found' }
90
+ session.enqueueNotification(msg.notification)
91
+ return { ok: true, queued: session.queueSize }
92
+ })
93
+
94
+ ipc.on(MSG.AGENT_STOPPING, (msg) => {
95
+ const session = sessions.get(msg.sessionId)
96
+ if (!session || session.state === STATE.ENDED) return { action: 'allow' }
97
+
98
+ if (session.decisionStrategy === DECISION.BREAK) {
99
+ session.break()
100
+ return { action: 'allow' }
101
+ }
102
+
103
+ // Default: WAIT
104
+ return {
105
+ action: 'block',
106
+ remainingFormatted: formatMs(session.remaining),
107
+ }
108
+ })
109
+
110
+ // ── Session event wiring ──────────────────────────────────────────────────────
111
+
112
+ function wireSessionEvents(session) {
113
+ session.on('tick', (data) => {
114
+ ipc.broadcast({ type: EVT.TICK, ...data })
115
+ })
116
+
117
+ session.on('overtime', () => {
118
+ const now = Date.now()
119
+ session.flushNotifications((item) => {
120
+ const releasedAt = now
121
+ const event = {
122
+ type: EVT.NOTIFICATION,
123
+ sessionId: session.id,
124
+ notification: item.notification,
125
+ receivedAt: item.receivedAt,
126
+ releasedAt,
127
+ }
128
+ ipc.broadcast(event)
129
+ const dbId = dbIds.get(session.id)
130
+ if (dbId) saveNotification(dbId, {
131
+ notificationJson: JSON.stringify(item.notification),
132
+ receivedAt: item.receivedAt,
133
+ releasedAt,
134
+ })
135
+ })
136
+ })
137
+
138
+ session.on('ended', ({ sessionId, status, actualMs }) => {
139
+ // Flush remaining notifications (if ended before overtime)
140
+ const now = Date.now()
141
+ session.flushNotifications((item) => {
142
+ const releasedAt = now
143
+ const event = {
144
+ type: EVT.NOTIFICATION,
145
+ sessionId,
146
+ notification: item.notification,
147
+ receivedAt: item.receivedAt,
148
+ releasedAt,
149
+ }
150
+ ipc.broadcast(event)
151
+ const dbId = dbIds.get(sessionId)
152
+ if (dbId) saveNotification(dbId, {
153
+ notificationJson: JSON.stringify(item.notification),
154
+ receivedAt: item.receivedAt,
155
+ releasedAt,
156
+ })
157
+ })
158
+
159
+ const dbId = dbIds.get(sessionId)
160
+ if (dbId) {
161
+ const agentSummary = readAgentSummary(session.projectDir)
162
+ endSession(dbId, { actualMs, status, agentSummary })
163
+ }
164
+
165
+ sessions.delete(sessionId)
166
+ dbIds.delete(sessionId)
167
+
168
+ ipc.broadcast({ type: EVT.SESSION_ENDED, sessionId, status, actualMs })
169
+ ipc.broadcast({ type: EVT.SNAPSHOT, sessions: [...sessions.values()].map((s) => s.snapshot()) })
170
+ })
171
+ }
172
+
173
+ // ── Startup ───────────────────────────────────────────────────────────────────
174
+
175
+ export async function startDaemon() {
176
+ const socketPath = getSocketPath()
177
+ await ipc.listen(socketPath)
178
+
179
+ writeLock({ pid: process.pid, socketPath, startedAt: Date.now() })
180
+
181
+ process.on('SIGTERM', shutdown)
182
+ process.on('SIGINT', shutdown)
183
+
184
+ return ipc
185
+ }
186
+
187
+ function shutdown() {
188
+ for (const session of sessions.values()) session.break()
189
+ removeLock()
190
+ ipc.close()
191
+ setTimeout(() => process.exit(0), 300)
192
+ }
193
+
194
+ // ── Helpers ───────────────────────────────────────────────────────────────────
195
+
196
+ function formatMs(ms) {
197
+ const m = Math.floor(ms / 60000)
198
+ const s = Math.floor((ms % 60000) / 1000)
199
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
200
+ }
201
+
202
+ function readAgentSummary(projectDir) {
203
+ const summaryPath = join(projectDir, '.claude', 'pomodoro-summary.md')
204
+ if (!existsSync(summaryPath)) return null
205
+ try {
206
+ const text = readFileSync(summaryPath, 'utf8').trim()
207
+ // Keep first 500 chars for storage; full file stays on disk
208
+ return text.slice(0, 500) || null
209
+ } catch {
210
+ return null
211
+ }
212
+ }
@@ -0,0 +1,96 @@
1
+ import net from 'node:net'
2
+ import { existsSync, unlinkSync } from 'node:fs'
3
+ import { encode, EVT } from '../shared/protocol.mjs'
4
+
5
+ /**
6
+ * Unix domain socket server for daemon IPC.
7
+ * Handles message routing between hooks/TUI and the daemon.
8
+ */
9
+ export class IpcServer {
10
+ #server = null
11
+ #subscribers = new Set()
12
+ #handlers = {}
13
+
14
+ /**
15
+ * Register a handler for incoming message type.
16
+ * Handler receives (parsedMsg, socket) and should return a response object or null.
17
+ */
18
+ on(type, handler) {
19
+ this.#handlers[type] = handler
20
+ }
21
+
22
+ async listen(socketPath) {
23
+ // Clean up stale socket file
24
+ if (existsSync(socketPath)) {
25
+ unlinkSync(socketPath)
26
+ }
27
+
28
+ return new Promise((resolve, reject) => {
29
+ this.#server = net.createServer((socket) => {
30
+ this.#handleConnection(socket)
31
+ })
32
+
33
+ this.#server.once('error', reject)
34
+ this.#server.listen(socketPath, () => {
35
+ this.#server.removeListener('error', reject)
36
+ this.#server.on('error', (err) => {
37
+ console.error('[ipc] server error:', err.message)
38
+ })
39
+ resolve()
40
+ })
41
+ })
42
+ }
43
+
44
+ #handleConnection(socket) {
45
+ let buffer = ''
46
+
47
+ socket.on('data', async (chunk) => {
48
+ buffer += chunk.toString()
49
+ let newline
50
+ while ((newline = buffer.indexOf('\n')) !== -1) {
51
+ const line = buffer.slice(0, newline).trim()
52
+ buffer = buffer.slice(newline + 1)
53
+ if (!line) continue
54
+
55
+ let msg
56
+ try { msg = JSON.parse(line) } catch { continue }
57
+
58
+ if (msg.type === 'subscribe') {
59
+ this.#subscribers.add(socket)
60
+ socket.once('close', () => this.#subscribers.delete(socket))
61
+ // Send current state immediately on subscribe
62
+ const handler = this.#handlers['query']
63
+ if (handler) {
64
+ const state = await handler(msg, socket)
65
+ if (state) socket.write(encode({ type: EVT.STATE, ...state }))
66
+ }
67
+ continue
68
+ }
69
+
70
+ const handler = this.#handlers[msg.type]
71
+ if (handler) {
72
+ const response = await handler(msg, socket)
73
+ if (response !== null && response !== undefined) {
74
+ socket.write(encode(response))
75
+ }
76
+ }
77
+ }
78
+ })
79
+
80
+ socket.on('error', () => {
81
+ this.#subscribers.delete(socket)
82
+ })
83
+ }
84
+
85
+ /** Broadcast an event to all subscribed clients (TUI) */
86
+ broadcast(event) {
87
+ const data = encode(event)
88
+ for (const socket of this.#subscribers) {
89
+ try { socket.write(data) } catch {}
90
+ }
91
+ }
92
+
93
+ close() {
94
+ this.#server?.close()
95
+ }
96
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Holds notifications intercepted during a Pomodoro session.
3
+ * Released all at once when the session enters overtime or ends.
4
+ */
5
+ export class NotificationQueue {
6
+ #queue = []
7
+ #onFlush = null
8
+
9
+ /** Register callback invoked with each notification when flushed */
10
+ onFlush(cb) {
11
+ this.#onFlush = cb
12
+ }
13
+
14
+ enqueue(notification) {
15
+ this.#queue.push({
16
+ notification,
17
+ receivedAt: Date.now(),
18
+ })
19
+ }
20
+
21
+ flush() {
22
+ const items = [...this.#queue]
23
+ this.#queue = []
24
+ if (this.#onFlush) {
25
+ for (const item of items) {
26
+ this.#onFlush(item)
27
+ }
28
+ }
29
+ return items
30
+ }
31
+
32
+ get size() {
33
+ return this.#queue.length
34
+ }
35
+
36
+ toArray() {
37
+ return [...this.#queue]
38
+ }
39
+ }
@@ -0,0 +1,109 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { STATE } from '../shared/protocol.mjs'
4
+ import { NotificationQueue } from './notificationQueue.mjs'
5
+
6
+ /**
7
+ * A single Pomodoro session with its own timer and notification queue.
8
+ */
9
+ export class Session extends EventEmitter {
10
+ id
11
+ projectDir
12
+ projectHash
13
+ task
14
+ plannedMs
15
+ decisionStrategy
16
+ startedAt
17
+
18
+ #state = STATE.RUNNING
19
+ #overtimeAt = null
20
+ #interval = null
21
+ #queue = new NotificationQueue()
22
+
23
+ constructor({ projectDir, projectHash, task, plannedMs, decisionStrategy }) {
24
+ super()
25
+ this.id = randomUUID()
26
+ this.projectDir = projectDir
27
+ this.projectHash = projectHash
28
+ this.task = task
29
+ this.plannedMs = plannedMs
30
+ this.decisionStrategy = decisionStrategy
31
+ this.startedAt = Date.now()
32
+
33
+ this.#interval = setInterval(() => this.#tick(), 1000)
34
+ this.#tick()
35
+ }
36
+
37
+ get state() { return this.#state }
38
+
39
+ get remaining() {
40
+ if (this.#state === STATE.OVERTIME || this.#state === STATE.ENDED) return 0
41
+ return Math.max(0, this.plannedMs - (Date.now() - this.startedAt))
42
+ }
43
+
44
+ get overtime() {
45
+ if (!this.#overtimeAt) return 0
46
+ return Date.now() - this.#overtimeAt
47
+ }
48
+
49
+ get actualMs() {
50
+ return Date.now() - this.startedAt
51
+ }
52
+
53
+ get queueSize() {
54
+ return this.#queue.size
55
+ }
56
+
57
+ snapshot() {
58
+ return {
59
+ sessionId: this.id,
60
+ projectDir: this.projectDir,
61
+ task: this.task,
62
+ plannedMs: this.plannedMs,
63
+ state: this.#state,
64
+ remaining: this.remaining,
65
+ overtime: this.overtime,
66
+ queuedNotifications: this.#queue.size,
67
+ startedAt: this.startedAt,
68
+ }
69
+ }
70
+
71
+ enqueueNotification(notification) {
72
+ this.#queue.enqueue(notification)
73
+ }
74
+
75
+ flushNotifications(onItem) {
76
+ this.#queue.onFlush(onItem)
77
+ this.#queue.flush()
78
+ }
79
+
80
+ /** User pressed [E] End Session */
81
+ end() { this.#finish('completed') }
82
+
83
+ /** User pressed [B] Break */
84
+ break() { this.#finish('broken') }
85
+
86
+ #tick() {
87
+ if (this.#state === STATE.RUNNING && this.remaining === 0) {
88
+ this.#state = STATE.OVERTIME
89
+ this.#overtimeAt = Date.now()
90
+ this.emit('overtime')
91
+ }
92
+ if (this.#state !== STATE.ENDED) {
93
+ this.emit('tick', {
94
+ sessionId: this.id,
95
+ state: this.#state,
96
+ remaining: this.remaining,
97
+ overtime: this.overtime,
98
+ queuedNotifications: this.#queue.size,
99
+ })
100
+ }
101
+ }
102
+
103
+ #finish(status) {
104
+ if (this.#state === STATE.ENDED) return
105
+ clearInterval(this.#interval)
106
+ this.#state = STATE.ENDED
107
+ this.emit('ended', { sessionId: this.id, status, actualMs: this.actualMs })
108
+ }
109
+ }