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 +2 -2
- package/README.zh.md +2 -2
- package/bin/pomodoro.mjs +60 -8
- package/install.mjs +19 -1
- package/package.json +1 -3
- package/src/daemon/db.mjs +102 -124
- package/src/daemon/index.mjs +8 -1
- package/src/daemon/ipc.mjs +1 -1
- package/src/daemon/session.mjs +1 -0
- package/src/hooks/notification.mjs +4 -8
- package/src/hooks/preToolUse.mjs +4 -8
- package/src/hooks/stop.mjs +5 -8
- package/src/shared/findActiveSession.mjs +27 -0
- package/src/tui/timer/TimerApp.tsx +12 -5
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
|
-
|
|
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 |
|
|
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"` / ๅ็งฐ |
|
|
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(
|
|
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(
|
|
117
|
-
console.log(`
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
5
|
+
const DATA_PATH = join(homedir(), '.claude', 'pomodoro-data.json')
|
|
7
6
|
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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 (!
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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(
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
106
|
-
const projects =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
122
|
-
return
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
|
152
|
+
String(row.total_minutes).padStart(9) +
|
|
169
153
|
` ${date}`
|
|
170
154
|
)
|
|
171
155
|
}
|
|
172
156
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
console.log(` ๐ค ${line}`)
|
|
182
|
+
console.log(` ๐ค ${s.user_activity.slice(0, 68)}`)
|
|
205
183
|
}
|
|
206
184
|
}
|
|
207
185
|
|
package/src/daemon/index.mjs
CHANGED
|
@@ -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 })
|
package/src/daemon/ipc.mjs
CHANGED
|
@@ -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.
|
|
65
|
+
if (state) socket.write(encode({ type: EVT.SNAPSHOT, ...state }))
|
|
66
66
|
}
|
|
67
67
|
continue
|
|
68
68
|
}
|
package/src/daemon/session.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Notification hook โ queue notifications during an active Pomodoro session.
|
|
3
3
|
*/
|
|
4
|
-
import { readLock
|
|
4
|
+
import { readLock } from '../shared/lockfile.mjs'
|
|
5
5
|
import { sendAndReceive } from '../shared/ipcClient.mjs'
|
|
6
|
-
import {
|
|
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
|
|
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()
|
package/src/hooks/preToolUse.mjs
CHANGED
|
@@ -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
|
|
5
|
+
import { readLock } from '../shared/lockfile.mjs'
|
|
6
6
|
import { sendAndReceive } from '../shared/ipcClient.mjs'
|
|
7
|
-
import {
|
|
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
|
-
|
|
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({
|
package/src/hooks/stop.mjs
CHANGED
|
@@ -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
|
|
5
|
+
import { readLock } from '../shared/lockfile.mjs'
|
|
6
6
|
import { sendAndReceive } from '../shared/ipcClient.mjs'
|
|
7
|
-
import {
|
|
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
|
|
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
|
}
|