vibe-pomo 0.2.0 → 0.2.2

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
@@ -27,6 +27,8 @@ Every session is logged — what the agent did, what you did — giving you a cl
27
27
 
28
28
  **Prerequisites:** Node.js 20+, Claude Code CLI
29
29
 
30
+ **Recommended:** run Claude Code inside `tmux`. That is the primary supported interactive timer experience: vibe-pomo uses a `tmux` popup to cover the current pane during a Pomodoro. Outside `tmux`, sessions still run, but timer UI falls back to headless mode.
31
+
30
32
  ```bash
31
33
  npm i -g vibe-pomo
32
34
  pomodoro install
@@ -71,6 +73,8 @@ pomodoro daemon
71
73
 
72
74
  ### 2. Start a session
73
75
 
76
+ For the intended covered-screen workflow, open Claude Code inside a `tmux` session before running `/pomodoro ...`.
77
+
74
78
  ```bash
75
79
  # From Claude Code (recommended)
76
80
  /pomodoro 25m Refactor the auth module
@@ -80,7 +84,7 @@ pomodoro start 25m Refactor the auth module
80
84
  pomodoro start Refactor the auth module # uses default duration
81
85
  ```
82
86
 
83
- A timer window opens in a new terminal. The agent starts working. You're free.
87
+ 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 stays headless: the session still runs, but visualization remains in the daemon dashboard and stats view instead of opening another terminal window.
84
88
 
85
89
  ```
86
90
  🍅 Pomodoro
@@ -163,7 +167,7 @@ Recent Sessions
163
167
  |--------|--------|-------------|
164
168
  | `defaultDurationMs` | ms | Default session duration (25 min = `1500000`) |
165
169
  | `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. |
170
+ | `terminalEmulator` | `"auto"` / `"tmux"` / `"none"` | Timer surface. `"tmux"` opens a popup overlay, `"none"` forces headless mode, and `"auto"` uses the popup only when `$TMUX` is set. |
167
171
  | `soundOnOvertime` | bool | Play a sound when the timer hits zero |
168
172
 
169
173
  ---
package/README.zh.md CHANGED
@@ -84,6 +84,8 @@ What did you do during this session?
84
84
 
85
85
  **前置条件:** Node.js 20+、Claude Code CLI
86
86
 
87
+ **推荐条件:** 请在 `tmux` 里运行 Claude Code。这是 vibe-pomo 当前主推的交互方式:番茄钟会通过 `tmux popup` 覆盖当前 pane。非 `tmux` 环境下,会话仍然会正常运行,但计时器 UI 会退化为无界面模式。
88
+
87
89
  ```bash
88
90
  npm install -g vibe-pomo
89
91
  pomodoro install
@@ -111,6 +113,8 @@ pomodoro daemon
111
113
 
112
114
  ### 2. 开始一个会话
113
115
 
116
+ 如果你希望获得“盖住当前 Claude Code 窗口”的预期体验,请先在 `tmux` session 里打开 Claude Code,再执行 `/pomodoro ...`。
117
+
114
118
  ```bash
115
119
  # 在 Claude Code 中(推荐)
116
120
  /pomodoro 25m Refactor the auth module
@@ -120,7 +124,7 @@ pomodoro start 25m Refactor the auth module
120
124
  pomodoro start Refactor the auth module # 使用默认时长
121
125
  ```
122
126
 
123
- 计时器窗口打开,代理开始工作,你自由了。
127
+ 如果 Claude Code 跑在 `tmux` 里,vibe-pomo 会在当前 pane 上弹出一个覆盖式 popup,把正在进行的任务界面盖住,直到你结束或中断番茄钟。非 `tmux` 环境下则保持无界面模式:会话照常运行,但只通过 daemon 仪表板和统计视图展示,不再额外弹出终端窗口。
124
128
 
125
129
  ### 3. 会话进行中
126
130
 
@@ -177,7 +181,7 @@ Recent Sessions
177
181
  |------|------|------|
178
182
  | `defaultDurationMs` | 毫秒数 | 默认会话时长(25 分钟 = `1500000`) |
179
183
  | `decisionStrategy` | `"wait"` / `"break"` | 代理被阻塞时的策略:静默等待直到你结束会话(默认),或立即结束 |
180
- | `terminalEmulator` | `"auto"` / 名称 | 计时器窗口使用的终端。从 `$TERM_PROGRAM`、`$KITTY_WINDOW_ID` 等自动检测 |
184
+ | `terminalEmulator` | `"auto"` / `"tmux"` / `"none"` | 计时器展示方式。`"tmux"` 打开覆盖式 popup,`"none"` 强制无界面模式,`"auto"` 仅在检测到 `$TMUX` 时使用 popup |
181
185
  | `soundOnOvertime` | 布尔值 | 计时归零时播放提示音 |
182
186
 
183
187
  ---
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'
@@ -108,14 +109,16 @@ async function cmdStart(args) {
108
109
  spawnWatcher(resp.sessionId)
109
110
  }
110
111
 
111
- // Launch timer TUI in a new terminal window
112
+ // Only launch an interactive timer UI when we can safely cover the current
113
+ // Claude Code pane with a tmux popup. Otherwise stay headless and let the
114
+ // daemon/dashboard own visualization so the conversation flow is not disturbed.
112
115
  const timerEntry = join(ROOT, 'src', 'tui', 'timer', 'index.tsx')
113
- const launched = await launchTerminal(TSX, timerEntry, resp.sessionId, config.terminalEmulator)
114
-
115
- if (!launched) {
116
- console.log(`\nOpen the timer in a new terminal:`)
117
- console.log(` tsx ${timerEntry} ${resp.sessionId}`)
118
- }
116
+ await launchTerminal({
117
+ tsx: TSX,
118
+ entryFile: timerEntry,
119
+ sessionId: resp.sessionId,
120
+ termPref: config.terminalEmulator,
121
+ })
119
122
  }
120
123
 
121
124
  async function cmdStop(args) {
@@ -179,46 +182,24 @@ async function cmdStopDaemon() {
179
182
 
180
183
  // ── Terminal launch helpers ───────────────────────────────────────────────────
181
184
 
182
- async function launchTerminal(tsx, entryFile, sessionId, termPref) {
183
- const terminals = termPref && termPref !== 'auto'
184
- ? [termPref]
185
- : detectTerminals()
186
-
187
- for (const term of terminals) {
188
- const ok = await trySpawn(term, tsx, entryFile, sessionId)
189
- if (ok) return true
190
- }
191
- return false
185
+ async function launchTerminal({ tsx, entryFile, sessionId, termPref }) {
186
+ const mode = resolveTimerMode(termPref)
187
+ if (mode !== 'tmux-popup') return false
188
+ return trySpawn(mode, tsx, entryFile, sessionId)
192
189
  }
193
190
 
194
- function detectTerminals() {
195
- const list = []
196
- if (isWin) {
197
- list.push('wt', 'cmd')
198
- return list
191
+ function resolveTimerMode(termPref) {
192
+ const pref = termPref ?? 'auto'
193
+ if (pref === 'none' || pref === 'headless') return 'none'
194
+ if ((pref === 'auto' || pref === 'tmux' || pref === 'tmux-popup') && process.env.TMUX) {
195
+ return 'tmux-popup'
199
196
  }
200
- if (process.env.KITTY_WINDOW_ID) list.push('kitty')
201
- if (process.env.TERM_PROGRAM === 'WezTerm') list.push('wezterm')
202
- list.push('gnome-terminal', 'xfce4-terminal', 'konsole', 'xterm', 'alacritty', 'wezterm', 'kitty')
203
- return list
197
+ return 'none'
204
198
  }
205
199
 
206
200
  function buildCmd(term, tsx, entryFile, sessionId) {
207
- // Quote paths for Windows (handles spaces in paths like AppData\Roaming\npm\...)
208
- const q = isWin ? (p) => `"${p}"` : (p) => p
209
- const inner = isWin
210
- ? `${q(tsx)} ${q(entryFile)} ${sessionId}`
211
- : `${tsx} ${entryFile} ${sessionId}`
212
201
  switch (term) {
213
- case 'wt': return ['wt', 'new-tab', '--', 'cmd.exe', '/k', inner]
214
- case 'cmd': return ['cmd.exe', '/c', `start cmd.exe /k "${inner}"`]
215
- case 'gnome-terminal': return ['gnome-terminal', '--', 'bash', '-c', inner]
216
- case 'xfce4-terminal': return ['xfce4-terminal', '-e', inner]
217
- case 'konsole': return ['konsole', '-e', inner]
218
- case 'xterm': return ['xterm', '-e', inner]
219
- case 'alacritty': return ['alacritty', '-e', 'bash', '-c', inner]
220
- case 'wezterm': return ['wezterm', 'start', '--', 'bash', '-c', inner]
221
- case 'kitty': return ['kitty', 'bash', '-c', inner]
202
+ case 'tmux-popup': return buildTmuxPopupCmd(tsx, entryFile, sessionId)
222
203
  default: return null
223
204
  }
224
205
  }
@@ -244,8 +225,7 @@ function isInsideClaudeCode() {
244
225
 
245
226
  function spawnWatcher(sessionId) {
246
227
  const watcherPath = join(ROOT, 'src', 'watcher.mjs')
247
- // Watch the grandparent process (Claude Code's Node.js process)
248
- const watchPid = process.ppid
228
+ const watchPid = getWatchPid()
249
229
  const child = spawn(process.execPath, [watcherPath, String(watchPid), sessionId], {
250
230
  detached: true,
251
231
  stdio: 'ignore',
@@ -253,6 +233,48 @@ function spawnWatcher(sessionId) {
253
233
  child.unref()
254
234
  }
255
235
 
236
+ function buildTmuxPopupCmd(tsx, entryFile, sessionId) {
237
+ const timerCmd = [
238
+ 'POMODORO_MODAL=1',
239
+ shellQuote(process.execPath),
240
+ shellQuote(tsx),
241
+ shellQuote(entryFile),
242
+ shellQuote(sessionId),
243
+ ].join(' ')
244
+
245
+ return [
246
+ 'tmux',
247
+ 'display-popup',
248
+ '-E',
249
+ '-w', '100%',
250
+ '-h', '100%',
251
+ '-x', '0',
252
+ '-y', '0',
253
+ '-B',
254
+ '-T', 'Pomodoro',
255
+ `bash -lc ${shellQuote(timerCmd)}`,
256
+ ]
257
+ }
258
+
259
+ function shellQuote(value) {
260
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
261
+ }
262
+
263
+ function getWatchPid() {
264
+ if (isWin) return process.ppid
265
+
266
+ try {
267
+ const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(process.ppid)], {
268
+ encoding: 'utf8',
269
+ stdio: ['ignore', 'pipe', 'ignore'],
270
+ }).trim()
271
+ const ppid = Number.parseInt(out, 10)
272
+ if (Number.isInteger(ppid) && ppid > 1) return ppid
273
+ } catch {}
274
+
275
+ return process.ppid
276
+ }
277
+
256
278
  function formatMs(ms) {
257
279
  const m = Math.floor(ms / 60000)
258
280
  const s = Math.floor((ms % 60000) / 1000)
package/install.mjs CHANGED
@@ -113,11 +113,14 @@ node ${pomodoroPath} start $ARGUMENTS
113
113
 
114
114
  The user has started a Pomodoro focus session and **will not be checking the screen** until the timer ends. Do not interrupt them.
115
115
 
116
+ **Current project**: Check \`\$CLAUDE_PROJECT_DIR\` or run \`pwd\` — work only on files in that directory. Do not reference or modify any other project.
117
+
116
118
  **Task**: $ARGUMENTS
117
119
 
118
120
  - Focus only on what is **unambiguously clear** from the task description
119
121
  - If you encounter something that requires a user decision, **stop and record it** in \`.claude/pomodoro-pending.md\` — do NOT make assumptions or proceed on the user's behalf
120
122
  - Do not send notifications or ask questions — the user is in focus mode
123
+ - If a timer UI is available, it may appear as a tmux popup. If not, continue headlessly — do not mention missing timer UI to the user unless they ask
121
124
  - When you have done all you can, write a summary to \`.claude/pomodoro-summary.md\`
122
125
  - Then wait quietly — the Pomodoro hook will manage the session lifecycle
123
126
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-pomo",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": {
@@ -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
  }