vibe-pomo 0.1.3 → 0.2.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.
Files changed (3) hide show
  1. package/install.mjs +58 -81
  2. package/package.json +1 -3
  3. package/src/daemon/db.mjs +102 -124
package/install.mjs CHANGED
@@ -7,45 +7,45 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
7
7
  import { homedir } from 'node:os'
8
8
  import { join, dirname } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
- import { createRequire } from 'node:module'
11
10
 
12
11
  const __dirname = dirname(fileURLToPath(import.meta.url))
13
12
  const HOOKS_DIR = join(__dirname, 'src', 'hooks')
14
13
  const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json')
15
14
 
15
+ // Normalize path separators for bash compatibility on Windows.
16
+ // Claude Code runs hooks via bash even on Windows, so backslashes must
17
+ // become forward slashes. E.g. E:\path\node.exe → E:/path/node.exe
18
+ const normPath = (p) => p.replace(/\\/g, '/')
19
+
20
+ // Quote a path only if it contains spaces.
21
+ const q = (p) => { const n = normPath(p); return n.includes(' ') ? `"${n}"` : n }
22
+
23
+ // The hook script files vibe-pomo owns — used to detect and replace stale entries.
24
+ const HOOK_SCRIPTS = ['preToolUse.mjs', 'notification.mjs', 'stop.mjs']
25
+
26
+ function isVibePomоHook(hookEntry) {
27
+ return hookEntry.hooks?.some((h) =>
28
+ HOOK_SCRIPTS.some((f) => h.command?.includes(f))
29
+ )
30
+ }
31
+
16
32
  export function runInstall() {
17
- const nodeExec = process.execPath
18
- // On Windows, backslashes in paths break bash (used by Claude Code hooks).
19
- // Convert to forward slashes, which work in both cmd/PowerShell and Git Bash.
20
- const normPath = (p) => p.replace(/\\/g, '/')
21
- const q = (p) => { const n = normPath(p); return n.includes(' ') ? `"${n}"` : n }
33
+ // Use 'node' from PATH rather than process.execPath so the command works
34
+ // across all shells (bash, PowerShell, cmd) without platform-specific path issues.
35
+ const nodeCmd = 'node'
36
+ const scriptPath = (name) => q(join(HOOKS_DIR, name))
22
37
 
23
38
  const hookDefs = {
24
- PreToolUse: [
25
- {
26
- matcher: '.*',
27
- hooks: [{
28
- type: 'command',
29
- command: `${q(nodeExec)} ${q(join(HOOKS_DIR, 'preToolUse.mjs'))}`,
30
- }],
31
- },
32
- ],
33
- Notification: [
34
- {
35
- hooks: [{
36
- type: 'command',
37
- command: `${q(nodeExec)} ${q(join(HOOKS_DIR, 'notification.mjs'))}`,
38
- }],
39
- },
40
- ],
41
- Stop: [
42
- {
43
- hooks: [{
44
- type: 'command',
45
- command: `${q(nodeExec)} ${q(join(HOOKS_DIR, 'stop.mjs'))}`,
46
- }],
47
- },
48
- ],
39
+ PreToolUse: [{
40
+ matcher: '.*',
41
+ hooks: [{ type: 'command', command: `${nodeCmd} ${scriptPath('preToolUse.mjs')}` }],
42
+ }],
43
+ Notification: [{
44
+ hooks: [{ type: 'command', command: `${nodeCmd} ${scriptPath('notification.mjs')}` }],
45
+ }],
46
+ Stop: [{
47
+ hooks: [{ type: 'command', command: `${nodeCmd} ${scriptPath('stop.mjs')}` }],
48
+ }],
49
49
  }
50
50
 
51
51
  // Read existing settings
@@ -61,33 +61,21 @@ export function runInstall() {
61
61
  mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
62
62
  }
63
63
 
64
- // Merge hooks — avoid duplicates by checking command paths
65
64
  settings.hooks = settings.hooks ?? {}
66
65
 
67
- let added = 0
66
+ // Replace any existing vibe-pomo hooks (removes stale/duplicate entries
67
+ // from previous installs that may have used backslash paths or full node paths).
68
68
  for (const [event, defs] of Object.entries(hookDefs)) {
69
- settings.hooks[event] = settings.hooks[event] ?? []
70
- for (const def of defs) {
71
- const commandToAdd = def.hooks[0].command
72
- const alreadyRegistered = settings.hooks[event].some(
73
- (existing) => existing.hooks?.some((h) => h.command === commandToAdd)
74
- )
75
- if (!alreadyRegistered) {
76
- settings.hooks[event].push(def)
77
- added++
78
- }
79
- }
69
+ settings.hooks[event] = (settings.hooks[event] ?? []).filter(
70
+ (entry) => !isVibePomоHook(entry)
71
+ )
72
+ settings.hooks[event].push(...defs)
80
73
  }
81
74
 
82
75
  writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8')
76
+ console.log(`✓ Registered hooks in ${SETTINGS_PATH}`)
83
77
 
84
- if (added > 0) {
85
- console.log(`✓ Registered ${added} hook(s) in ${SETTINGS_PATH}`)
86
- } else {
87
- console.log(`Hooks already registered in ${SETTINGS_PATH}`)
88
- }
89
-
90
- // Also install project-local slash command
78
+ // Also install project-local slash commands
91
79
  installSlashCommand()
92
80
  }
93
81
 
@@ -95,25 +83,22 @@ function installSlashCommand() {
95
83
  const globalCommandsDir = join(homedir(), '.claude', 'commands')
96
84
  mkdirSync(globalCommandsDir, { recursive: true })
97
85
 
86
+ const pomodoroPath = q(join(__dirname, 'bin', 'pomodoro.mjs'))
87
+
98
88
  const commands = [
99
- { file: 'pomodoro.md', writer: writePomodoroCommand },
100
- { file: 'pomodoro-stats.md', writer: writeStatsCommand },
101
- { file: 'pomodoro-stop.md', writer: writeStopCommand },
89
+ { file: 'pomodoro.md', content: pomodoroCommand(pomodoroPath) },
90
+ { file: 'pomodoro-stats.md', content: statsCommand(pomodoroPath) },
91
+ { file: 'pomodoro-stop.md', content: stopCommand(pomodoroPath) },
102
92
  ]
103
93
 
104
- for (const { file, writer } of commands) {
105
- const dest = join(globalCommandsDir, file)
106
- writer(dest)
94
+ for (const { file, content } of commands) {
95
+ writeFileSync(join(globalCommandsDir, file), content, 'utf8')
107
96
  console.log(`✓ Slash command: ~/.claude/commands/${file}`)
108
97
  }
109
98
  }
110
99
 
111
- function writePomodoroCommand(dest) {
112
- const pomodoroPath = join(__dirname, 'bin', 'pomodoro.mjs')
113
- const nodeExec = process.execPath
114
- const normPath = (p) => p.replace(/\\/g, '/')
115
- const q = (p) => { const n = normPath(p); return n.includes(' ') ? `"${n}"` : n }
116
- writeFileSync(dest, `---
100
+ function pomodoroCommand(pomodoroPath) {
101
+ return `---
117
102
  description: Start a Pomodoro focus session — agent works autonomously until timer ends
118
103
  argument-hint: "[duration e.g. 25m] [task description]"
119
104
  ---
@@ -121,7 +106,7 @@ argument-hint: "[duration e.g. 25m] [task description]"
121
106
  ## Step 1: Start the Pomodoro timer
122
107
 
123
108
  \`\`\`bash
124
- ${q(nodeExec)} ${q(pomodoroPath)} start $ARGUMENTS
109
+ node ${pomodoroPath} start $ARGUMENTS
125
110
  \`\`\`
126
111
 
127
112
  ## Step 2: Work autonomously during the focus session
@@ -135,43 +120,35 @@ The user has started a Pomodoro focus session and **will not be checking the scr
135
120
  - Do not send notifications or ask questions — the user is in focus mode
136
121
  - When you have done all you can, write a summary to \`.claude/pomodoro-summary.md\`
137
122
  - Then wait quietly — the Pomodoro hook will manage the session lifecycle
138
- `, 'utf8')
123
+ `
139
124
  }
140
125
 
141
- function writeStatsCommand(dest) {
142
- const pomodoroPath = join(__dirname, 'bin', 'pomodoro.mjs')
143
- const nodeExec = process.execPath
144
- const normPath = (p) => p.replace(/\\/g, '/')
145
- const q = (p) => { const n = normPath(p); return n.includes(' ') ? `"${n}"` : n }
146
- writeFileSync(dest, `---
126
+ function statsCommand(pomodoroPath) {
127
+ return `---
147
128
  description: Show Pomodoro time tracking statistics
148
129
  ---
149
130
 
150
131
  Run the following command and display the output to the user:
151
132
 
152
133
  \`\`\`bash
153
- ${q(nodeExec)} ${q(pomodoroPath)} stats
134
+ node ${pomodoroPath} stats
154
135
  \`\`\`
155
- `, 'utf8')
136
+ `
156
137
  }
157
138
 
158
- function writeStopCommand(dest) {
159
- const pomodoroPath = join(__dirname, 'bin', 'pomodoro.mjs')
160
- const nodeExec = process.execPath
161
- const normPath = (p) => p.replace(/\\/g, '/')
162
- const q = (p) => { const n = normPath(p); return n.includes(' ') ? `"${n}"` : n }
163
- writeFileSync(dest, `---
139
+ function stopCommand(pomodoroPath) {
140
+ return `---
164
141
  description: Break (stop) the current Pomodoro session
165
142
  ---
166
143
 
167
144
  Run the following command to break the active Pomodoro session:
168
145
 
169
146
  \`\`\`bash
170
- ${q(nodeExec)} ${q(pomodoroPath)} stop
147
+ node ${pomodoroPath} stop
171
148
  \`\`\`
172
149
 
173
150
  Then confirm to the user that the session has been stopped.
174
- `, 'utf8')
151
+ `
175
152
  }
176
153
 
177
154
  // Run directly if called as script
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-pomo",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "You and your agent, both in flow. A Pomodoro timer for Claude Code that keeps agents working autonomously while you stay deep in focus — uninterrupted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,14 +33,12 @@
33
33
  "tui": "tsx src/tui/index.tsx"
34
34
  },
35
35
  "dependencies": {
36
- "better-sqlite3": "^9.4.3",
37
36
  "ink": "^5.1.0",
38
37
  "ink-text-input": "^6.0.0",
39
38
  "react": "^18.3.1",
40
39
  "tsx": "^4.19.2"
41
40
  },
42
41
  "devDependencies": {
43
- "@types/better-sqlite3": "^7.6.8",
44
42
  "@types/react": "^18.3.1"
45
43
  },
46
44
  "engines": {
package/src/daemon/db.mjs CHANGED
@@ -1,146 +1,130 @@
1
- import Database from 'better-sqlite3'
2
1
  import { homedir } from 'node:os'
3
2
  import { join } from 'node:path'
4
- import { mkdirSync } from 'node:fs'
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
5
4
 
6
- const DB_PATH = join(homedir(), '.claude', 'pomodoro.db')
5
+ const DATA_PATH = join(homedir(), '.claude', 'pomodoro-data.json')
7
6
 
8
- function openDb() {
7
+ // ── In-memory store (loaded once, flushed on every write) ─────────────────────
8
+
9
+ let _data = null
10
+
11
+ function load() {
12
+ if (_data) return _data
9
13
  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`)
14
+ if (existsSync(DATA_PATH)) {
15
+ try { _data = JSON.parse(readFileSync(DATA_PATH, 'utf8')) } catch {}
57
16
  }
58
- if (!cols.includes('user_activity')) {
59
- db.exec(`ALTER TABLE sessions ADD COLUMN user_activity TEXT`)
17
+ if (!_data) _data = { sessions: [], notifications: [], nextId: 1 }
18
+ // Backfill nextId in case file was created without it
19
+ if (!_data.nextId) {
20
+ const maxId = Math.max(0, ...(_data.sessions ?? []).map((s) => s.id ?? 0))
21
+ _data.nextId = maxId + 1
60
22
  }
61
-
62
- return db
23
+ return _data
63
24
  }
64
25
 
65
- let _db = null
66
- function getDb() {
67
- if (!_db) _db = openDb()
68
- return _db
26
+ function flush() {
27
+ writeFileSync(DATA_PATH, JSON.stringify(_data, null, 2), 'utf8')
69
28
  }
70
29
 
30
+ // ── Public API (mirrors the old better-sqlite3 interface exactly) ─────────────
31
+
71
32
  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
33
+ const data = load()
34
+ const id = data.nextId++
35
+ data.sessions.push({
36
+ id,
37
+ project_hash: projectHash,
38
+ project_path: projectPath,
39
+ started_at: Date.now(),
40
+ ended_at: null,
41
+ planned_ms: plannedMs,
42
+ actual_ms: null,
43
+ cycles: 1,
44
+ status: 'running',
45
+ task: task ?? null,
46
+ agent_summary: null,
47
+ user_activity: null,
48
+ })
49
+ flush()
50
+ return id
78
51
  }
79
52
 
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)
53
+ export function endSession(id, { actualMs, status, agentSummary }) {
54
+ const data = load()
55
+ const s = data.sessions.find((s) => s.id === id)
56
+ if (!s) return
57
+ s.ended_at = Date.now()
58
+ s.actual_ms = actualMs
59
+ s.status = status
60
+ s.agent_summary = agentSummary ?? null
61
+ flush()
87
62
  }
88
63
 
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)
64
+ export function updateSessionActivity(id, { userActivity }) {
65
+ const data = load()
66
+ const s = data.sessions.find((s) => s.id === id)
67
+ if (!s) return
68
+ s.user_activity = userActivity ?? null
69
+ flush()
93
70
  }
94
71
 
95
72
  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)
73
+ const data = load()
74
+ data.notifications.push({
75
+ id: data.nextId++,
76
+ session_id: sessionId,
77
+ received_at: receivedAt,
78
+ released_at: releasedAt ?? null,
79
+ notification_json: notificationJson,
80
+ })
81
+ flush()
82
+ }
83
+
84
+ // ── Aggregation helpers ───────────────────────────────────────────────────────
85
+
86
+ function projectStats() {
87
+ const data = load()
88
+ const map = new Map()
89
+ for (const s of data.sessions) {
90
+ if (s.status === 'broken') continue
91
+ const key = s.project_path
92
+ if (!map.has(key)) map.set(key, { project_path: key, total_sessions: 0, total_cycles: 0, total_minutes: 0, last_active: 0 })
93
+ const row = map.get(key)
94
+ row.total_sessions++
95
+ row.total_cycles += s.cycles ?? 1
96
+ row.total_minutes += Math.floor((s.actual_ms ?? 0) / 60000)
97
+ if (s.started_at > row.last_active) row.last_active = s.started_at
98
+ }
99
+ return [...map.values()].sort((a, b) => b.last_active - a.last_active)
101
100
  }
102
101
 
103
102
  /** Return stats for Dashboard TUI */
104
103
  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
-
104
+ const data = load()
105
+ const projects = projectStats().slice(0, 10)
106
+ const recent = [...data.sessions]
107
+ .sort((a, b) => b.started_at - a.started_at)
108
+ .slice(0, 8)
109
+ .map(({ project_path, task, actual_ms, status, started_at, agent_summary, user_activity }) =>
110
+ ({ project_path, task, actual_ms, status, started_at, agent_summary, user_activity }))
116
111
  return { projects, recent }
117
112
  }
118
113
 
119
114
  /** Return all sessions for a project, for detailed review */
120
115
  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)
116
+ const data = load()
117
+ return data.sessions
118
+ .filter((s) => s.project_path === projectPath)
119
+ .sort((a, b) => b.started_at - a.started_at)
120
+ .map(({ id, task, started_at, ended_at, actual_ms, status, agent_summary, user_activity }) =>
121
+ ({ id, task, started_at, ended_at, actual_ms, status, agent_summary, user_activity }))
128
122
  }
129
123
 
130
124
  /** Print stats to stdout as a formatted table */
131
125
  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()
126
+ const data = load()
127
+ const projects = projectStats()
144
128
 
145
129
  if (projects.length === 0) {
146
130
  console.log('No completed sessions yet.')
@@ -165,18 +149,14 @@ export function printStats() {
165
149
  truncated.padEnd(36) +
166
150
  String(row.total_sessions).padStart(9) +
167
151
  String(row.total_cycles).padStart(8) +
168
- String(row.total_minutes ?? 0).padStart(9) +
152
+ String(row.total_minutes).padStart(9) +
169
153
  ` ${date}`
170
154
  )
171
155
  }
172
156
 
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()
157
+ const recent = [...data.sessions]
158
+ .sort((a, b) => b.started_at - a.started_at)
159
+ .slice(0, 10)
180
160
 
181
161
  console.log('\nRecent Sessions\n' + '─'.repeat(72))
182
162
  console.log(
@@ -191,17 +171,15 @@ export function printStats() {
191
171
  for (const s of recent) {
192
172
  const taskLabel = (s.task ?? '(no task)').slice(0, 37).padEnd(38)
193
173
  const mins = s.actual_ms ? String(Math.round(s.actual_ms / 60000)).padStart(5) : ' —'
194
- const cyc = String(s.cycles).padStart(5)
174
+ const cyc = String(s.cycles ?? 1).padStart(5)
195
175
  const status = s.status.padEnd(12)
196
176
  const date = new Date(s.started_at).toLocaleDateString()
197
177
  console.log(`${taskLabel}${mins}${cyc} ${status} ${date}`)
198
178
  if (s.agent_summary) {
199
- const line = s.agent_summary.split('\n')[0].slice(0, 68)
200
- console.log(` 🤖 ${line}`)
179
+ console.log(` 🤖 ${s.agent_summary.split('\n')[0].slice(0, 68)}`)
201
180
  }
202
181
  if (s.user_activity) {
203
- const line = s.user_activity.slice(0, 68)
204
- console.log(` 👤 ${line}`)
182
+ console.log(` 👤 ${s.user_activity.slice(0, 68)}`)
205
183
  }
206
184
  }
207
185