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.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/bin/pomodoro.mjs +249 -0
- package/install.mjs +170 -0
- package/package.json +49 -0
- package/src/daemon/db.mjs +209 -0
- package/src/daemon/index.mjs +212 -0
- package/src/daemon/ipc.mjs +96 -0
- package/src/daemon/notificationQueue.mjs +39 -0
- package/src/daemon/session.mjs +109 -0
- package/src/daemon/timer.mjs +100 -0
- package/src/hooks/notification.mjs +53 -0
- package/src/hooks/preToolUse.mjs +38 -0
- package/src/hooks/stop.mjs +70 -0
- package/src/shared/config.mjs +58 -0
- package/src/shared/ipcClient.mjs +73 -0
- package/src/shared/lockfile.mjs +51 -0
- package/src/shared/protocol.mjs +44 -0
- package/src/tui/App.tsx +149 -0
- package/src/tui/components/NotificationLog.tsx +60 -0
- package/src/tui/components/TaskBar.tsx +23 -0
- package/src/tui/components/Timer.tsx +47 -0
- package/src/tui/dashboard/ActiveSessions.tsx +67 -0
- package/src/tui/dashboard/DashboardApp.tsx +110 -0
- package/src/tui/dashboard/ProjectChart.tsx +60 -0
- package/src/tui/dashboard/RecentSessions.tsx +79 -0
- package/src/tui/dashboard/index.tsx +31 -0
- package/src/tui/index.tsx +56 -0
- package/src/tui/ipcClient.mjs +52 -0
- package/src/tui/timer/TimerApp.tsx +164 -0
- package/src/tui/timer/index.tsx +51 -0
- package/src/watcher.mjs +51 -0
|
@@ -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
|
+
}
|