vibe-pomo 0.1.4 โ†’ 0.2.1

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/README.md CHANGED
@@ -80,7 +80,7 @@ pomodoro start 25m Refactor the auth module
80
80
  pomodoro start Refactor the auth module # uses default duration
81
81
  ```
82
82
 
83
- A timer window opens in a new terminal. The agent starts working. You're free.
83
+ If Claude Code is running inside `tmux`, vibe-pomo opens a popup over the current pane so the ongoing task stays covered until you end or break the session. Outside `tmux`, it falls back to a separate timer terminal.
84
84
 
85
85
  ```
86
86
  ๐Ÿ… Pomodoro
@@ -163,7 +163,7 @@ Recent Sessions
163
163
  |--------|--------|-------------|
164
164
  | `defaultDurationMs` | ms | Default session duration (25 min = `1500000`) |
165
165
  | `decisionStrategy` | `"wait"` / `"break"` | When the agent is blocked: wait silently until you end the session (default), or end immediately |
166
- | `terminalEmulator` | `"auto"` / name | Terminal for the timer window. Auto-detects from `$TERM_PROGRAM`, `$KITTY_WINDOW_ID`, etc. |
166
+ | `terminalEmulator` | `"auto"` / `"tmux"` / `"tmux-window"` / name | Timer surface. `"tmux"` opens a popup overlay, `"tmux-window"` uses a new tmux window, and `"auto"` prefers the popup when `$TMUX` is set. |
167
167
  | `soundOnOvertime` | bool | Play a sound when the timer hits zero |
168
168
 
169
169
  ---
package/README.zh.md CHANGED
@@ -120,7 +120,7 @@ pomodoro start 25m Refactor the auth module
120
120
  pomodoro start Refactor the auth module # ไฝฟ็”จ้ป˜่ฎคๆ—ถ้•ฟ
121
121
  ```
122
122
 
123
- ่ฎกๆ—ถๅ™จ็ช—ๅฃๆ‰“ๅผ€๏ผŒไปฃ็†ๅผ€ๅง‹ๅทฅไฝœ๏ผŒไฝ ่‡ช็”ฑไบ†ใ€‚
123
+ ๅฆ‚ๆžœ Claude Code ่ท‘ๅœจ `tmux` ้‡Œ๏ผŒvibe-pomo ไผšๅœจๅฝ“ๅ‰ pane ไธŠๅผนๅ‡บไธ€ไธช่ฆ†็›–ๅผ popup๏ผŒๆŠŠๆญฃๅœจ่ฟ›่กŒ็š„ไปปๅŠก็•Œ้ข็›–ไฝ๏ผŒ็›ดๅˆฐไฝ ็ป“ๆŸๆˆ–ไธญๆ–ญ็•ช่Œ„้’Ÿใ€‚้ž `tmux` ็Žฏๅขƒไธ‹ๅˆ™ๅ›ž้€€ๅˆฐๅ•็‹ฌ็š„่ฎกๆ—ถๅ™จ็ปˆ็ซฏ็ช—ๅฃใ€‚
124
124
 
125
125
  ### 3. ไผš่ฏ่ฟ›่กŒไธญ
126
126
 
@@ -177,7 +177,7 @@ Recent Sessions
177
177
  |------|------|------|
178
178
  | `defaultDurationMs` | ๆฏซ็ง’ๆ•ฐ | ้ป˜่ฎคไผš่ฏๆ—ถ้•ฟ๏ผˆ25 ๅˆ†้’Ÿ = `1500000`๏ผ‰ |
179
179
  | `decisionStrategy` | `"wait"` / `"break"` | ไปฃ็†่ขซ้˜ปๅกžๆ—ถ็š„็ญ–็•ฅ๏ผš้™้ป˜็ญ‰ๅพ…็›ดๅˆฐไฝ ็ป“ๆŸไผš่ฏ๏ผˆ้ป˜่ฎค๏ผ‰๏ผŒๆˆ–็ซ‹ๅณ็ป“ๆŸ |
180
- | `terminalEmulator` | `"auto"` / ๅ็งฐ | ่ฎกๆ—ถๅ™จ็ช—ๅฃไฝฟ็”จ็š„็ปˆ็ซฏใ€‚ไปŽ `$TERM_PROGRAM`ใ€`$KITTY_WINDOW_ID` ็ญ‰่‡ชๅŠจๆฃ€ๆต‹ |
180
+ | `terminalEmulator` | `"auto"` / `"tmux"` / `"tmux-window"` / ๅ็งฐ | ่ฎกๆ—ถๅ™จๅฑ•็คบๆ–นๅผใ€‚`"tmux"` ๆ‰“ๅผ€่ฆ†็›–ๅผ popup๏ผŒ`"tmux-window"` ไฝฟ็”จๆ–ฐ็š„ tmux window๏ผŒ`"auto"` ๅœจๆฃ€ๆต‹ๅˆฐ `$TMUX` ๆ—ถไผ˜ๅ…ˆไฝฟ็”จ popup |
181
181
  | `soundOnOvertime` | ๅธƒๅฐ”ๅ€ผ | ่ฎกๆ—ถๅฝ’้›ถๆ—ถๆ’ญๆ”พๆ็คบ้Ÿณ |
182
182
 
183
183
  ---
package/bin/pomodoro.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process'
3
+ import { execFileSync } from 'node:child_process'
3
4
  import { fileURLToPath } from 'node:url'
4
5
  import { dirname, join } from 'node:path'
5
6
  import { readLock, removeLock } from '../src/shared/lockfile.mjs'
@@ -110,11 +111,17 @@ async function cmdStart(args) {
110
111
 
111
112
  // Launch timer TUI in a new terminal window
112
113
  const timerEntry = join(ROOT, 'src', 'tui', 'timer', 'index.tsx')
113
- const launched = await launchTerminal(TSX, timerEntry, resp.sessionId, config.terminalEmulator)
114
+ const launched = await launchTerminal({
115
+ tsx: TSX,
116
+ entryFile: timerEntry,
117
+ sessionId: resp.sessionId,
118
+ termPref: config.terminalEmulator,
119
+ })
114
120
 
115
121
  if (!launched) {
116
- console.log(`\nOpen the timer in a new terminal:`)
117
- console.log(` tsx ${timerEntry} ${resp.sessionId}`)
122
+ console.log(`POMODORO_TIMER_NOT_LAUNCHED`)
123
+ console.log(` To watch the timer: node ${TSX} ${timerEntry} ${resp.sessionId}`)
124
+ console.log(` To end the session: node ${join(ROOT, 'bin', 'pomodoro.mjs')} stop`)
118
125
  }
119
126
  }
120
127
 
@@ -179,7 +186,7 @@ async function cmdStopDaemon() {
179
186
 
180
187
  // โ”€โ”€ Terminal launch helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
181
188
 
182
- async function launchTerminal(tsx, entryFile, sessionId, termPref) {
189
+ async function launchTerminal({ tsx, entryFile, sessionId, termPref }) {
183
190
  const terminals = termPref && termPref !== 'auto'
184
191
  ? [termPref]
185
192
  : detectTerminals()
@@ -197,9 +204,12 @@ function detectTerminals() {
197
204
  list.push('wt', 'cmd')
198
205
  return list
199
206
  }
200
- if (process.env.KITTY_WINDOW_ID) list.push('kitty')
207
+ // Prefer a modal overlay when Claude Code is running inside tmux.
208
+ if (process.env.TMUX) list.push('tmux-popup')
209
+ if (process.env.STY) list.push('screen')
210
+ if (process.env.KITTY_WINDOW_ID) list.push('kitty')
201
211
  if (process.env.TERM_PROGRAM === 'WezTerm') list.push('wezterm')
202
- list.push('gnome-terminal', 'xfce4-terminal', 'konsole', 'xterm', 'alacritty', 'wezterm', 'kitty')
212
+ list.push('tmux-window', 'gnome-terminal', 'xfce4-terminal', 'konsole', 'xterm', 'alacritty', 'wezterm', 'kitty')
203
213
  return list
204
214
  }
205
215
 
@@ -212,6 +222,10 @@ function buildCmd(term, tsx, entryFile, sessionId) {
212
222
  switch (term) {
213
223
  case 'wt': return ['wt', 'new-tab', '--', 'cmd.exe', '/k', inner]
214
224
  case 'cmd': return ['cmd.exe', '/c', `start cmd.exe /k "${inner}"`]
225
+ case 'tmux':
226
+ case 'tmux-popup': return buildTmuxPopupCmd(tsx, entryFile, sessionId)
227
+ case 'tmux-window': return ['tmux', 'new-window', inner]
228
+ case 'screen': return ['screen', '-X', 'screen', 'bash', '-c', inner]
215
229
  case 'gnome-terminal': return ['gnome-terminal', '--', 'bash', '-c', inner]
216
230
  case 'xfce4-terminal': return ['xfce4-terminal', '-e', inner]
217
231
  case 'konsole': return ['konsole', '-e', inner]
@@ -244,8 +258,7 @@ function isInsideClaudeCode() {
244
258
 
245
259
  function spawnWatcher(sessionId) {
246
260
  const watcherPath = join(ROOT, 'src', 'watcher.mjs')
247
- // Watch the grandparent process (Claude Code's Node.js process)
248
- const watchPid = process.ppid
261
+ const watchPid = getWatchPid()
249
262
  const child = spawn(process.execPath, [watcherPath, String(watchPid), sessionId], {
250
263
  detached: true,
251
264
  stdio: 'ignore',
@@ -253,6 +266,45 @@ function spawnWatcher(sessionId) {
253
266
  child.unref()
254
267
  }
255
268
 
269
+ function buildTmuxPopupCmd(tsx, entryFile, sessionId) {
270
+ const timerCmd = [
271
+ 'POMODORO_MODAL=1',
272
+ shellQuote(process.execPath),
273
+ shellQuote(tsx),
274
+ shellQuote(entryFile),
275
+ shellQuote(sessionId),
276
+ ].join(' ')
277
+
278
+ return [
279
+ 'tmux',
280
+ 'display-popup',
281
+ '-E',
282
+ '-w', '90%',
283
+ '-h', '90%',
284
+ '-T', 'Pomodoro',
285
+ `bash -lc ${shellQuote(timerCmd)}`,
286
+ ]
287
+ }
288
+
289
+ function shellQuote(value) {
290
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
291
+ }
292
+
293
+ function getWatchPid() {
294
+ if (isWin) return process.ppid
295
+
296
+ try {
297
+ const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(process.ppid)], {
298
+ encoding: 'utf8',
299
+ stdio: ['ignore', 'pipe', 'ignore'],
300
+ }).trim()
301
+ const ppid = Number.parseInt(out, 10)
302
+ if (Number.isInteger(ppid) && ppid > 1) return ppid
303
+ } catch {}
304
+
305
+ return process.ppid
306
+ }
307
+
256
308
  function formatMs(ms) {
257
309
  const m = Math.floor(ms / 60000)
258
310
  const s = Math.floor((ms % 60000) / 1000)
package/install.mjs CHANGED
@@ -109,10 +109,28 @@ argument-hint: "[duration e.g. 25m] [task description]"
109
109
  node ${pomodoroPath} start $ARGUMENTS
110
110
  \`\`\`
111
111
 
112
- ## Step 2: Work autonomously during the focus session
112
+ ## Step 2: Check if timer window launched
113
+
114
+ Read the output from Step 1 carefully.
115
+
116
+ If the output contains **\`POMODORO_TIMER_NOT_LAUNCHED\`**, the system could not open a separate timer window (common in SSH, VSCode, or headless environments). In this case you MUST tell the user BEFORE doing any work:
117
+
118
+ > โš ๏ธ **No timer window could be opened automatically.**
119
+ >
120
+ > Run one of these in another terminal to control your Pomodoro:
121
+ > - **Watch timer:** (the command shown on the "To watch" line above)
122
+ > - **End session:** (the command shown on the "To end" line above)
123
+ >
124
+ > I'll now work on your task. The session will stay active until you run the stop command.
125
+
126
+ After telling the user that once, continue immediately. Do not wait for a reply.
127
+
128
+ ## Step 3: Work autonomously during the focus session
113
129
 
114
130
  The user has started a Pomodoro focus session and **will not be checking the screen** until the timer ends. Do not interrupt them.
115
131
 
132
+ **Current project**: Check \`\$CLAUDE_PROJECT_DIR\` or run \`pwd\` โ€” work only on files in that directory. Do not reference or modify any other project.
133
+
116
134
  **Task**: $ARGUMENTS
117
135
 
118
136
  - Focus only on what is **unambiguously clear** from the task description
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-pomo",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
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
 
@@ -21,6 +21,8 @@ const sessions = new Map()
21
21
 
22
22
  /** sessionId โ†’ DB row id */
23
23
  const dbIds = new Map()
24
+ /** sessionId โ†’ DB row id for sessions that already ended but may still receive user activity */
25
+ const finishedDbIds = new Map()
24
26
 
25
27
  // โ”€โ”€ IPC Handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
26
28
 
@@ -78,9 +80,10 @@ ipc.on(MSG.SESSION_BREAK, (msg) => {
78
80
  })
79
81
 
80
82
  ipc.on(MSG.SESSION_UPDATE_ACTIVITY, (msg) => {
81
- const dbId = dbIds.get(msg.sessionId)
83
+ const dbId = dbIds.get(msg.sessionId) ?? finishedDbIds.get(msg.sessionId)
82
84
  if (!dbId) return { error: 'session not found' }
83
85
  updateSessionActivity(dbId, { userActivity: msg.userActivity })
86
+ finishedDbIds.delete(msg.sessionId)
84
87
  return { ok: true }
85
88
  })
86
89
 
@@ -163,6 +166,10 @@ function wireSessionEvents(session) {
163
166
  }
164
167
 
165
168
  sessions.delete(sessionId)
169
+ if (dbId) {
170
+ finishedDbIds.set(sessionId, dbId)
171
+ setTimeout(() => finishedDbIds.delete(sessionId), 30 * 60 * 1000).unref()
172
+ }
166
173
  dbIds.delete(sessionId)
167
174
 
168
175
  ipc.broadcast({ type: EVT.SESSION_ENDED, sessionId, status, actualMs })
@@ -62,7 +62,7 @@ export class IpcServer {
62
62
  const handler = this.#handlers['query']
63
63
  if (handler) {
64
64
  const state = await handler(msg, socket)
65
- if (state) socket.write(encode({ type: EVT.STATE, ...state }))
65
+ if (state) socket.write(encode({ type: EVT.SNAPSHOT, ...state }))
66
66
  }
67
67
  continue
68
68
  }
@@ -58,6 +58,7 @@ export class Session extends EventEmitter {
58
58
  return {
59
59
  sessionId: this.id,
60
60
  projectDir: this.projectDir,
61
+ projectHash: this.projectHash,
61
62
  task: this.task,
62
63
  plannedMs: this.plannedMs,
63
64
  state: this.#state,
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Notification hook โ€” queue notifications during an active Pomodoro session.
3
3
  */
4
- import { readLock, projectHash } from '../shared/lockfile.mjs'
4
+ import { readLock } from '../shared/lockfile.mjs'
5
5
  import { sendAndReceive } from '../shared/ipcClient.mjs'
6
- import { STATE, MSG } from '../shared/protocol.mjs'
6
+ import { MSG } from '../shared/protocol.mjs'
7
+ import { findActiveSession } from '../shared/findActiveSession.mjs'
7
8
 
8
9
  async function readStdin() {
9
10
  return new Promise((resolve) => {
@@ -20,8 +21,6 @@ async function main() {
20
21
  if (!lock) process.exit(0)
21
22
 
22
23
  const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
23
- const hash = projectHash(projectDir)
24
-
25
24
  let state
26
25
  try {
27
26
  state = await sendAndReceive({ type: MSG.QUERY })
@@ -29,10 +28,7 @@ async function main() {
29
28
  process.exit(0)
30
29
  }
31
30
 
32
- const active = state.sessions?.find(
33
- (s) => s.projectHash === hash &&
34
- (s.state === STATE.RUNNING || s.state === STATE.OVERTIME)
35
- )
31
+ const active = findActiveSession(state.sessions, projectDir)
36
32
  if (!active) process.exit(0)
37
33
 
38
34
  const raw = await readStdin()
@@ -2,9 +2,10 @@
2
2
  * PreToolUse hook โ€” auto-approve tool calls during an active Pomodoro session.
3
3
  * Transparency: if no daemon running or no active session for this project, exits 0.
4
4
  */
5
- import { readLock, projectHash } from '../shared/lockfile.mjs'
5
+ import { readLock } from '../shared/lockfile.mjs'
6
6
  import { sendAndReceive } from '../shared/ipcClient.mjs'
7
- import { STATE, MSG } from '../shared/protocol.mjs'
7
+ import { MSG } from '../shared/protocol.mjs'
8
+ import { findActiveSession } from '../shared/findActiveSession.mjs'
8
9
 
9
10
  async function main() {
10
11
  const lock = readLock()
@@ -19,12 +20,7 @@ async function main() {
19
20
  process.exit(0)
20
21
  }
21
22
 
22
- // Find an active session for this project
23
- const hash = projectHash(projectDir)
24
- const active = state.sessions?.find(
25
- (s) => s.projectHash === hash &&
26
- (s.state === STATE.RUNNING || s.state === STATE.OVERTIME)
27
- )
23
+ const active = findActiveSession(state.sessions, projectDir)
28
24
 
29
25
  if (active) {
30
26
  process.stdout.write(JSON.stringify({
@@ -2,9 +2,10 @@
2
2
  * Stop hook โ€” called when Claude Code agent finishes.
3
3
  * Blocks the agent if a Pomodoro is active for this project.
4
4
  */
5
- import { readLock, projectHash } from '../shared/lockfile.mjs'
5
+ import { readLock } from '../shared/lockfile.mjs'
6
6
  import { sendAndReceive } from '../shared/ipcClient.mjs'
7
- import { STATE, MSG } from '../shared/protocol.mjs'
7
+ import { MSG } from '../shared/protocol.mjs'
8
+ import { findActiveSession } from '../shared/findActiveSession.mjs'
8
9
 
9
10
  async function readStdin() {
10
11
  return new Promise((resolve) => {
@@ -21,8 +22,6 @@ async function main() {
21
22
  if (!lock) process.exit(0)
22
23
 
23
24
  const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
24
- const hash = projectHash(projectDir)
25
-
26
25
  let state
27
26
  try {
28
27
  state = await sendAndReceive({ type: MSG.QUERY })
@@ -30,10 +29,7 @@ async function main() {
30
29
  process.exit(0)
31
30
  }
32
31
 
33
- const active = state.sessions?.find(
34
- (s) => s.projectHash === hash &&
35
- (s.state === STATE.RUNNING || s.state === STATE.OVERTIME)
36
- )
32
+ const active = findActiveSession(state.sessions, projectDir)
37
33
  if (!active) process.exit(0)
38
34
 
39
35
  const raw = await readStdin()
@@ -60,6 +56,7 @@ async function main() {
60
56
  `Time remaining: ${resp.remainingFormatted}.`,
61
57
  'The user is not available. Do not send messages or notifications.',
62
58
  'Record any pending decisions in `.claude/pomodoro-pending.md` and wait quietly.',
59
+ 'If the user asks how to end the session, tell them to run: pomodoro stop',
63
60
  ].join(' '),
64
61
  }) + '\n')
65
62
  }
@@ -0,0 +1,27 @@
1
+ import { resolve } from 'node:path'
2
+ import { projectHash } from './lockfile.mjs'
3
+ import { STATE } from './protocol.mjs'
4
+
5
+ function normalizeProjectDir(projectDir) {
6
+ return resolve(projectDir ?? process.cwd())
7
+ }
8
+
9
+ function isActiveSession(session) {
10
+ return session?.state === STATE.RUNNING || session?.state === STATE.OVERTIME
11
+ }
12
+
13
+ /**
14
+ * Match the current Claude project to the daemon's active session.
15
+ * Prefer the stable hash, but fall back to normalized projectDir for
16
+ * compatibility with older snapshots that did not include projectHash.
17
+ */
18
+ export function findActiveSession(sessions, projectDir) {
19
+ const normalizedDir = normalizeProjectDir(projectDir)
20
+ const hash = projectHash(normalizedDir)
21
+
22
+ return sessions?.find((session) => {
23
+ if (!isActiveSession(session)) return false
24
+ if (session.projectHash === hash) return true
25
+ return normalizeProjectDir(session.projectDir) === normalizedDir
26
+ }) ?? null
27
+ }
@@ -34,6 +34,7 @@ export function TimerApp({
34
34
  }: TimerAppProps) {
35
35
  const { stdout } = useStdout()
36
36
  const width = stdout?.columns ?? 60
37
+ const isModal = process.env.POMODORO_MODAL === '1'
37
38
 
38
39
  const [timerState, setTimerState] = useState(initialState)
39
40
  const [remaining, setRemaining] = useState(initialRemaining)
@@ -97,13 +98,13 @@ export function TimerApp({
97
98
  useInput((input, key) => {
98
99
  if (phase === 'activity-input') return // TextInput handles keys in this phase
99
100
  if (phase === 'done') {
100
- if (input === 'q' || key.escape) process.exit(0)
101
+ if (!isModal && (input === 'q' || key.escape)) process.exit(0)
101
102
  return
102
103
  }
103
104
  if (timerState === STATE.ENDED) return
104
105
  if (input === 'e' || input === 'E') sendEnd()
105
106
  if (input === 'b' || input === 'B') sendBreak()
106
- if (input === 'q' || key.escape) process.exit(0)
107
+ if (!isModal && (input === 'q' || key.escape)) process.exit(0)
107
108
  })
108
109
 
109
110
  const isOvertime = timerState === STATE.OVERTIME
@@ -141,10 +142,10 @@ export function TimerApp({
141
142
 
142
143
  // โ”€โ”€ Timer phase โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
143
144
  const keybindings = isEnded
144
- ? '[Q] Quit'
145
+ ? (isModal ? 'Submit your notes to close the Pomodoro overlay' : '[Q] Quit')
145
146
  : isOvertime
146
- ? '[E] End Session [B] Break [Q] Quit'
147
- : '[B] Break [Q] Quit'
147
+ ? (isModal ? '[E] End Session [B] Break' : '[E] End Session [B] Break [Q] Quit')
148
+ : (isModal ? '[B] Break' : '[B] Break [Q] Quit')
148
149
 
149
150
  return (
150
151
  <Box flexDirection="column" width={width} paddingX={2} paddingY={1}>
@@ -159,6 +160,12 @@ export function TimerApp({
159
160
  <Box marginTop={1}>
160
161
  <Text color="gray" dimColor>{keybindings}</Text>
161
162
  </Box>
163
+
164
+ {isModal && (
165
+ <Box marginTop={1}>
166
+ <Text color="gray" dimColor>The current Claude Code pane will stay covered until you end or break this Pomodoro.</Text>
167
+ </Box>
168
+ )}
162
169
  </Box>
163
170
  )
164
171
  }