vibe-pomo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/bin/pomodoro.mjs +249 -0
- package/install.mjs +170 -0
- package/package.json +49 -0
- package/src/daemon/db.mjs +209 -0
- package/src/daemon/index.mjs +212 -0
- package/src/daemon/ipc.mjs +96 -0
- package/src/daemon/notificationQueue.mjs +39 -0
- package/src/daemon/session.mjs +109 -0
- package/src/daemon/timer.mjs +100 -0
- package/src/hooks/notification.mjs +53 -0
- package/src/hooks/preToolUse.mjs +38 -0
- package/src/hooks/stop.mjs +70 -0
- package/src/shared/config.mjs +58 -0
- package/src/shared/ipcClient.mjs +73 -0
- package/src/shared/lockfile.mjs +51 -0
- package/src/shared/protocol.mjs +44 -0
- package/src/tui/App.tsx +149 -0
- package/src/tui/components/NotificationLog.tsx +60 -0
- package/src/tui/components/TaskBar.tsx +23 -0
- package/src/tui/components/Timer.tsx +47 -0
- package/src/tui/dashboard/ActiveSessions.tsx +67 -0
- package/src/tui/dashboard/DashboardApp.tsx +110 -0
- package/src/tui/dashboard/ProjectChart.tsx +60 -0
- package/src/tui/dashboard/RecentSessions.tsx +79 -0
- package/src/tui/dashboard/index.tsx +31 -0
- package/src/tui/index.tsx +56 -0
- package/src/tui/ipcClient.mjs +52 -0
- package/src/tui/timer/TimerApp.tsx +164 -0
- package/src/tui/timer/index.tsx +51 -0
- package/src/watcher.mjs +51 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { STATE } from '../shared/protocol.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Timer state machine:
|
|
6
|
+
* idle → running → overtime → ended
|
|
7
|
+
*
|
|
8
|
+
* Transitions:
|
|
9
|
+
* start() : idle → running
|
|
10
|
+
* (tick) : running → overtime when remaining hits 0
|
|
11
|
+
* end() : running|overtime → ended (user-initiated)
|
|
12
|
+
* break() : running|overtime → ended (user pressed Break)
|
|
13
|
+
*
|
|
14
|
+
* Events emitted:
|
|
15
|
+
* 'tick' { state, remaining, overtime } — every second
|
|
16
|
+
* 'overtime' {} — first tick at 0
|
|
17
|
+
* 'ended' { status } — final state
|
|
18
|
+
*/
|
|
19
|
+
export class Timer extends EventEmitter {
|
|
20
|
+
#state = STATE.IDLE
|
|
21
|
+
#plannedMs
|
|
22
|
+
#startedAt = null
|
|
23
|
+
#overtimeAt = null
|
|
24
|
+
#interval = null
|
|
25
|
+
|
|
26
|
+
constructor(plannedMs) {
|
|
27
|
+
super()
|
|
28
|
+
this.#plannedMs = plannedMs
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get state() { return this.#state }
|
|
32
|
+
get startedAt() { return this.#startedAt }
|
|
33
|
+
|
|
34
|
+
get remaining() {
|
|
35
|
+
if (!this.#startedAt) return this.#plannedMs
|
|
36
|
+
if (this.#state === STATE.OVERTIME || this.#state === STATE.ENDED) return 0
|
|
37
|
+
const elapsed = Date.now() - this.#startedAt
|
|
38
|
+
return Math.max(0, this.#plannedMs - elapsed)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get overtime() {
|
|
42
|
+
if (!this.#overtimeAt) return 0
|
|
43
|
+
return Date.now() - this.#overtimeAt
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get actualMs() {
|
|
47
|
+
if (!this.#startedAt) return 0
|
|
48
|
+
return Date.now() - this.#startedAt
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
snapshot() {
|
|
52
|
+
return {
|
|
53
|
+
state: this.#state,
|
|
54
|
+
remaining: this.remaining,
|
|
55
|
+
overtime: this.overtime,
|
|
56
|
+
plannedMs: this.#plannedMs,
|
|
57
|
+
startedAt: this.#startedAt,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
start() {
|
|
62
|
+
if (this.#state !== STATE.IDLE) return
|
|
63
|
+
this.#state = STATE.RUNNING
|
|
64
|
+
this.#startedAt = Date.now()
|
|
65
|
+
this.#interval = setInterval(() => this.#tick(), 1000)
|
|
66
|
+
this.#tick() // immediate first tick
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** User-initiated end (End Session button) */
|
|
70
|
+
end() {
|
|
71
|
+
this.#finish('completed')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** User-initiated break (Break button) */
|
|
75
|
+
break() {
|
|
76
|
+
this.#finish('broken')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#tick() {
|
|
80
|
+
if (this.#state === STATE.RUNNING && this.remaining === 0) {
|
|
81
|
+
this.#state = STATE.OVERTIME
|
|
82
|
+
this.#overtimeAt = Date.now()
|
|
83
|
+
this.emit('overtime')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.emit('tick', {
|
|
87
|
+
state: this.#state,
|
|
88
|
+
remaining: this.remaining,
|
|
89
|
+
overtime: this.overtime,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#finish(status) {
|
|
94
|
+
if (this.#state === STATE.ENDED) return
|
|
95
|
+
clearInterval(this.#interval)
|
|
96
|
+
this.#interval = null
|
|
97
|
+
this.#state = STATE.ENDED
|
|
98
|
+
this.emit('ended', { status, actualMs: this.actualMs })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification hook — queue notifications during an active Pomodoro session.
|
|
3
|
+
*/
|
|
4
|
+
import { readLock, projectHash } from '../shared/lockfile.mjs'
|
|
5
|
+
import { sendAndReceive } from '../shared/ipcClient.mjs'
|
|
6
|
+
import { STATE, MSG } from '../shared/protocol.mjs'
|
|
7
|
+
|
|
8
|
+
async function readStdin() {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
if (process.stdin.isTTY) return resolve('')
|
|
11
|
+
let data = ''
|
|
12
|
+
process.stdin.setEncoding('utf8')
|
|
13
|
+
process.stdin.on('data', (c) => { data += c })
|
|
14
|
+
process.stdin.on('end', () => resolve(data))
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const lock = readLock()
|
|
20
|
+
if (!lock) process.exit(0)
|
|
21
|
+
|
|
22
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
|
|
23
|
+
const hash = projectHash(projectDir)
|
|
24
|
+
|
|
25
|
+
let state
|
|
26
|
+
try {
|
|
27
|
+
state = await sendAndReceive({ type: MSG.QUERY })
|
|
28
|
+
} catch {
|
|
29
|
+
process.exit(0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const active = state.sessions?.find(
|
|
33
|
+
(s) => s.projectHash === hash &&
|
|
34
|
+
(s.state === STATE.RUNNING || s.state === STATE.OVERTIME)
|
|
35
|
+
)
|
|
36
|
+
if (!active) process.exit(0)
|
|
37
|
+
|
|
38
|
+
const raw = await readStdin()
|
|
39
|
+
let notification = {}
|
|
40
|
+
try { notification = JSON.parse(raw) } catch {}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await sendAndReceive({
|
|
44
|
+
type: MSG.QUEUE_NOTIFICATION,
|
|
45
|
+
sessionId: active.sessionId,
|
|
46
|
+
notification,
|
|
47
|
+
})
|
|
48
|
+
} catch {}
|
|
49
|
+
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main().catch(() => process.exit(0))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PreToolUse hook — auto-approve tool calls during an active Pomodoro session.
|
|
3
|
+
* Transparency: if no daemon running or no active session for this project, exits 0.
|
|
4
|
+
*/
|
|
5
|
+
import { readLock, projectHash } from '../shared/lockfile.mjs'
|
|
6
|
+
import { sendAndReceive } from '../shared/ipcClient.mjs'
|
|
7
|
+
import { STATE, MSG } from '../shared/protocol.mjs'
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
const lock = readLock()
|
|
11
|
+
if (!lock) process.exit(0)
|
|
12
|
+
|
|
13
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
|
|
14
|
+
|
|
15
|
+
let state
|
|
16
|
+
try {
|
|
17
|
+
state = await sendAndReceive({ type: MSG.QUERY })
|
|
18
|
+
} catch {
|
|
19
|
+
process.exit(0)
|
|
20
|
+
}
|
|
21
|
+
|
|
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
|
+
)
|
|
28
|
+
|
|
29
|
+
if (active) {
|
|
30
|
+
process.stdout.write(JSON.stringify({
|
|
31
|
+
hookSpecificOutput: { permissionDecision: 'allow' }
|
|
32
|
+
}) + '\n')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
process.exit(0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main().catch(() => process.exit(0))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop hook — called when Claude Code agent finishes.
|
|
3
|
+
* Blocks the agent if a Pomodoro is active for this project.
|
|
4
|
+
*/
|
|
5
|
+
import { readLock, projectHash } from '../shared/lockfile.mjs'
|
|
6
|
+
import { sendAndReceive } from '../shared/ipcClient.mjs'
|
|
7
|
+
import { STATE, MSG } from '../shared/protocol.mjs'
|
|
8
|
+
|
|
9
|
+
async function readStdin() {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
if (process.stdin.isTTY) return resolve('')
|
|
12
|
+
let data = ''
|
|
13
|
+
process.stdin.setEncoding('utf8')
|
|
14
|
+
process.stdin.on('data', (c) => { data += c })
|
|
15
|
+
process.stdin.on('end', () => resolve(data))
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
const lock = readLock()
|
|
21
|
+
if (!lock) process.exit(0)
|
|
22
|
+
|
|
23
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
|
|
24
|
+
const hash = projectHash(projectDir)
|
|
25
|
+
|
|
26
|
+
let state
|
|
27
|
+
try {
|
|
28
|
+
state = await sendAndReceive({ type: MSG.QUERY })
|
|
29
|
+
} catch {
|
|
30
|
+
process.exit(0)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const active = state.sessions?.find(
|
|
34
|
+
(s) => s.projectHash === hash &&
|
|
35
|
+
(s.state === STATE.RUNNING || s.state === STATE.OVERTIME)
|
|
36
|
+
)
|
|
37
|
+
if (!active) process.exit(0)
|
|
38
|
+
|
|
39
|
+
const raw = await readStdin()
|
|
40
|
+
let input = {}
|
|
41
|
+
try { input = JSON.parse(raw) } catch {}
|
|
42
|
+
|
|
43
|
+
let resp
|
|
44
|
+
try {
|
|
45
|
+
resp = await sendAndReceive({
|
|
46
|
+
type: MSG.AGENT_STOPPING,
|
|
47
|
+
sessionId: active.sessionId,
|
|
48
|
+
stopReason: input.stop_reason ?? input.reason,
|
|
49
|
+
})
|
|
50
|
+
} catch {
|
|
51
|
+
process.exit(0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (resp.action === 'block') {
|
|
55
|
+
process.stdout.write(JSON.stringify({
|
|
56
|
+
decision: 'block',
|
|
57
|
+
reason: `Pomodoro active (${resp.remainingFormatted} remaining). Waiting for user to end session.`,
|
|
58
|
+
systemMessage: [
|
|
59
|
+
'The Pomodoro focus session is still running.',
|
|
60
|
+
`Time remaining: ${resp.remainingFormatted}.`,
|
|
61
|
+
'The user is not available. Do not send messages or notifications.',
|
|
62
|
+
'Record any pending decisions in `.claude/pomodoro-pending.md` and wait quietly.',
|
|
63
|
+
].join(' '),
|
|
64
|
+
}) + '\n')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
process.exit(0)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main().catch(() => process.exit(0))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join, dirname } from 'node:path'
|
|
4
|
+
import { DECISION } from './protocol.mjs'
|
|
5
|
+
|
|
6
|
+
const CONFIG_PATH = join(homedir(), '.claude', 'pomodoro.json')
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
defaultDurationMs: 25 * 60 * 1000, // 25 minutes
|
|
10
|
+
decisionStrategy: DECISION.WAIT,
|
|
11
|
+
terminalEmulator: 'auto',
|
|
12
|
+
soundOnOvertime: true,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function loadConfig() {
|
|
16
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
17
|
+
return { ...DEFAULTS }
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
|
|
21
|
+
return { ...DEFAULTS, ...raw }
|
|
22
|
+
} catch {
|
|
23
|
+
return { ...DEFAULTS }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ensureConfigFile() {
|
|
28
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
29
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true })
|
|
30
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2) + '\n', 'utf8')
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatDuration(ms) {
|
|
37
|
+
const totalSec = Math.floor(ms / 1000)
|
|
38
|
+
const m = Math.floor(totalSec / 60)
|
|
39
|
+
const s = totalSec % 60
|
|
40
|
+
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse duration string like "25m", "1h", "90s", or plain number (minutes).
|
|
45
|
+
* Returns milliseconds.
|
|
46
|
+
*/
|
|
47
|
+
export function parseDuration(str) {
|
|
48
|
+
if (!str) return null
|
|
49
|
+
const s = String(str).trim()
|
|
50
|
+
const match = s.match(/^(\d+)(m|h|s)?$/)
|
|
51
|
+
if (!match) return null
|
|
52
|
+
const n = parseInt(match[1], 10)
|
|
53
|
+
const unit = match[2] || 'm'
|
|
54
|
+
if (unit === 'h') return n * 60 * 60 * 1000
|
|
55
|
+
if (unit === 'm') return n * 60 * 1000
|
|
56
|
+
if (unit === 's') return n * 1000
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import net from 'node:net'
|
|
2
|
+
import { encode } from './protocol.mjs'
|
|
3
|
+
import { getSocketPath } from './lockfile.mjs'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 500
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Send one message, receive one JSON response.
|
|
9
|
+
* Used by hook scripts and CLI commands.
|
|
10
|
+
*/
|
|
11
|
+
export function sendAndReceive(msgObj, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
12
|
+
const socketPath = getSocketPath()
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const client = net.createConnection(socketPath)
|
|
15
|
+
let buffer = ''
|
|
16
|
+
let settled = false
|
|
17
|
+
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
if (!settled) { settled = true; client.destroy(); reject(new Error('IPC timeout')) }
|
|
20
|
+
}, timeoutMs)
|
|
21
|
+
|
|
22
|
+
client.once('connect', () => client.write(encode(msgObj)))
|
|
23
|
+
|
|
24
|
+
client.on('data', (chunk) => {
|
|
25
|
+
buffer += chunk.toString()
|
|
26
|
+
const newline = buffer.indexOf('\n')
|
|
27
|
+
if (newline !== -1 && !settled) {
|
|
28
|
+
settled = true
|
|
29
|
+
clearTimeout(timer)
|
|
30
|
+
client.destroy()
|
|
31
|
+
try { resolve(JSON.parse(buffer.slice(0, newline))) }
|
|
32
|
+
catch { reject(new Error('Invalid JSON response')) }
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
client.on('error', (err) => {
|
|
37
|
+
if (!settled) { settled = true; clearTimeout(timer); reject(err) }
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Subscribe to daemon push events (for TUI clients).
|
|
44
|
+
* Returns { send(msg), close() }.
|
|
45
|
+
*/
|
|
46
|
+
export function subscribe(onEvent, onError) {
|
|
47
|
+
const socketPath = getSocketPath()
|
|
48
|
+
const client = net.createConnection(socketPath)
|
|
49
|
+
let buffer = ''
|
|
50
|
+
|
|
51
|
+
client.once('connect', () => {
|
|
52
|
+
client.write(encode({ type: 'subscribe' }))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
client.on('data', (chunk) => {
|
|
56
|
+
buffer += chunk.toString()
|
|
57
|
+
let newline
|
|
58
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
59
|
+
const line = buffer.slice(0, newline).trim()
|
|
60
|
+
buffer = buffer.slice(newline + 1)
|
|
61
|
+
if (!line) continue
|
|
62
|
+
try { onEvent(JSON.parse(line)) } catch {}
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
client.on('error', (err) => { if (onError) onError(err) })
|
|
67
|
+
client.on('close', () => { if (onError) onError(new Error('Connection closed')) })
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
send(msgObj) { try { client.write(encode(msgObj)) } catch {} },
|
|
71
|
+
close() { client.destroy() },
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const CLAUDE_DIR = join(homedir(), '.claude')
|
|
7
|
+
const LOCK_PATH = join(CLAUDE_DIR, 'pomodoro.lock')
|
|
8
|
+
const SOCKET_PATH = join(CLAUDE_DIR, 'pomodoro.sock')
|
|
9
|
+
|
|
10
|
+
export function getSocketPath() {
|
|
11
|
+
return SOCKET_PATH
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getLockPath() {
|
|
15
|
+
return LOCK_PATH
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read global lock. Returns null if file missing, malformed, or PID dead.
|
|
20
|
+
*/
|
|
21
|
+
export function readLock() {
|
|
22
|
+
if (!existsSync(LOCK_PATH)) return null
|
|
23
|
+
let data
|
|
24
|
+
try {
|
|
25
|
+
data = JSON.parse(readFileSync(LOCK_PATH, 'utf8'))
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
process.kill(data.pid, 0)
|
|
31
|
+
} catch {
|
|
32
|
+
try { unlinkSync(LOCK_PATH) } catch {}
|
|
33
|
+
try { unlinkSync(SOCKET_PATH) } catch {}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
return data
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeLock(data) {
|
|
40
|
+
writeFileSync(LOCK_PATH, JSON.stringify(data), 'utf8')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function removeLock() {
|
|
44
|
+
try { unlinkSync(LOCK_PATH) } catch {}
|
|
45
|
+
try { unlinkSync(SOCKET_PATH) } catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Stable short ID for a project directory */
|
|
49
|
+
export function projectHash(dir) {
|
|
50
|
+
return createHash('sha1').update(dir).digest('hex').slice(0, 12)
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC message type constants and builders.
|
|
3
|
+
* All messages are newline-delimited JSON over Unix domain socket.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Client → Daemon
|
|
7
|
+
export const MSG = {
|
|
8
|
+
QUERY: 'query', // get daemon state + all sessions
|
|
9
|
+
SESSION_QUERY: 'sessionQuery', // get single session state
|
|
10
|
+
SESSION_CREATE: 'sessionCreate', // start a new session
|
|
11
|
+
SESSION_END: 'sessionEnd', // user ends session (End button)
|
|
12
|
+
SESSION_BREAK: 'sessionBreak', // user breaks session (Break button)
|
|
13
|
+
AGENT_STOPPING: 'agentStopping', // stop hook: agent finished
|
|
14
|
+
QUEUE_NOTIFICATION: 'queueNotification',
|
|
15
|
+
SUBSCRIBE: 'subscribe',
|
|
16
|
+
SUBSCRIBE_SESSION: 'subscribeSession',
|
|
17
|
+
SESSION_UPDATE_ACTIVITY: 'sessionUpdateActivity', // save user's own activity note
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Daemon → Client (push)
|
|
21
|
+
export const EVT = {
|
|
22
|
+
TICK: 'tick', // { sessionId, state, remaining, overtime }
|
|
23
|
+
NOTIFICATION: 'notification', // { sessionId, notification, receivedAt, releasedAt }
|
|
24
|
+
SESSION_ENDED:'sessionEnded', // { sessionId, status, actualMs }
|
|
25
|
+
SNAPSHOT: 'snapshot', // full daemon state (on subscribe)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Timer states
|
|
29
|
+
export const STATE = {
|
|
30
|
+
IDLE: 'idle',
|
|
31
|
+
RUNNING: 'running',
|
|
32
|
+
OVERTIME: 'overtime',
|
|
33
|
+
ENDED: 'ended',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Decision strategies
|
|
37
|
+
export const DECISION = {
|
|
38
|
+
WAIT: 'wait', // block agent silently until user ends session
|
|
39
|
+
BREAK: 'break', // immediately end session
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function encode(obj) {
|
|
43
|
+
return JSON.stringify(obj) + '\n'
|
|
44
|
+
}
|
package/src/tui/App.tsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { Box, Text, useInput, useStdout } from 'ink'
|
|
3
|
+
import { Timer } from './components/Timer.js'
|
|
4
|
+
import { TaskBar } from './components/TaskBar.js'
|
|
5
|
+
import { NotificationLog } from './components/NotificationLog.js'
|
|
6
|
+
import { TuiIpcClient } from './ipcClient.mjs'
|
|
7
|
+
import { STATE } from '../shared/protocol.mjs'
|
|
8
|
+
|
|
9
|
+
interface AppProps {
|
|
10
|
+
socketPath: string
|
|
11
|
+
task: string
|
|
12
|
+
plannedMs: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface TickState {
|
|
16
|
+
state: string
|
|
17
|
+
remaining: number
|
|
18
|
+
overtime: number
|
|
19
|
+
queuedNotifications?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface NotificationEntry {
|
|
23
|
+
notification: Record<string, unknown>
|
|
24
|
+
receivedAt: number
|
|
25
|
+
releasedAt: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function App({ socketPath, task: initialTask, plannedMs }: AppProps) {
|
|
29
|
+
const { stdout } = useStdout()
|
|
30
|
+
const width = stdout?.columns ?? 60
|
|
31
|
+
|
|
32
|
+
const [tick, setTick] = useState<TickState>({
|
|
33
|
+
state: STATE.RUNNING,
|
|
34
|
+
remaining: plannedMs,
|
|
35
|
+
overtime: 0,
|
|
36
|
+
queuedNotifications: 0,
|
|
37
|
+
})
|
|
38
|
+
const [notifications, setNotifications] = useState<NotificationEntry[]>([])
|
|
39
|
+
const [task, setTask] = useState(initialTask)
|
|
40
|
+
const [connectionError, setConnectionError] = useState<string | null>(null)
|
|
41
|
+
const [ipc] = useState(() => new TuiIpcClient(socketPath))
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
ipc.connect({
|
|
45
|
+
onTick: (event) => {
|
|
46
|
+
setTick({
|
|
47
|
+
state: event.state,
|
|
48
|
+
remaining: event.remaining ?? 0,
|
|
49
|
+
overtime: event.overtime ?? 0,
|
|
50
|
+
queuedNotifications: event.queuedNotifications ?? 0,
|
|
51
|
+
})
|
|
52
|
+
if (event.task) setTask(event.task)
|
|
53
|
+
},
|
|
54
|
+
onNotification: (event) => {
|
|
55
|
+
setNotifications((prev) => [...prev, {
|
|
56
|
+
notification: event.notification,
|
|
57
|
+
receivedAt: event.receivedAt,
|
|
58
|
+
releasedAt: event.releasedAt,
|
|
59
|
+
}])
|
|
60
|
+
},
|
|
61
|
+
onEnded: () => {
|
|
62
|
+
setTick((prev) => ({ ...prev, state: STATE.ENDED }))
|
|
63
|
+
// Give time for final notifications to render, then exit
|
|
64
|
+
setTimeout(() => process.exit(0), 3000)
|
|
65
|
+
},
|
|
66
|
+
onError: (err) => {
|
|
67
|
+
if (tick.state !== STATE.ENDED) {
|
|
68
|
+
setConnectionError(err.message)
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return () => ipc.close()
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
const handleEnd = useCallback(async () => {
|
|
77
|
+
await ipc.sendEnd()
|
|
78
|
+
}, [ipc])
|
|
79
|
+
|
|
80
|
+
const handleBreak = useCallback(async () => {
|
|
81
|
+
await ipc.sendBreak()
|
|
82
|
+
}, [ipc])
|
|
83
|
+
|
|
84
|
+
useInput((input, key) => {
|
|
85
|
+
if (tick.state === STATE.ENDED) {
|
|
86
|
+
if (input === 'q' || key.escape) process.exit(0)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if (input === 'e' || input === 'E') handleEnd()
|
|
90
|
+
if (input === 'b' || input === 'B') handleBreak()
|
|
91
|
+
if (input === 'q' || key.escape) process.exit(0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const isOvertime = tick.state === STATE.OVERTIME
|
|
95
|
+
const isEnded = tick.state === STATE.ENDED
|
|
96
|
+
|
|
97
|
+
const keybindings = isEnded
|
|
98
|
+
? '[Q] Quit'
|
|
99
|
+
: isOvertime
|
|
100
|
+
? '[E] End Session [B] Break [Q] Quit'
|
|
101
|
+
: '[B] Break [Q] Quit'
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Box flexDirection="column" width={width} paddingX={2} paddingY={1}>
|
|
105
|
+
{/* Header */}
|
|
106
|
+
<Box marginBottom={1}>
|
|
107
|
+
<Text bold color={isOvertime ? 'yellow' : isEnded ? 'gray' : 'red'}>
|
|
108
|
+
🍅 Pomodoro
|
|
109
|
+
</Text>
|
|
110
|
+
</Box>
|
|
111
|
+
|
|
112
|
+
{/* Timer */}
|
|
113
|
+
<Timer
|
|
114
|
+
state={tick.state}
|
|
115
|
+
remaining={tick.remaining}
|
|
116
|
+
overtime={tick.overtime}
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
{/* Task */}
|
|
120
|
+
<TaskBar task={task} width={width} />
|
|
121
|
+
|
|
122
|
+
{/* Notification log */}
|
|
123
|
+
<NotificationLog
|
|
124
|
+
notifications={notifications}
|
|
125
|
+
queued={tick.queuedNotifications ?? 0}
|
|
126
|
+
width={width - 4}
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
{/* Error */}
|
|
130
|
+
{connectionError && (
|
|
131
|
+
<Box marginTop={1}>
|
|
132
|
+
<Text color="red">Connection lost: {connectionError}</Text>
|
|
133
|
+
</Box>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{/* Ended message */}
|
|
137
|
+
{isEnded && (
|
|
138
|
+
<Box marginTop={1}>
|
|
139
|
+
<Text color="green" bold>Session complete. Press Q to exit.</Text>
|
|
140
|
+
</Box>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{/* Keybindings */}
|
|
144
|
+
<Box marginTop={1}>
|
|
145
|
+
<Text color="gray" dimColor>{keybindings}</Text>
|
|
146
|
+
</Box>
|
|
147
|
+
</Box>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
|
|
4
|
+
interface NotificationEntry {
|
|
5
|
+
notification: Record<string, unknown>
|
|
6
|
+
receivedAt: number
|
|
7
|
+
releasedAt: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface NotificationLogProps {
|
|
11
|
+
notifications: NotificationEntry[]
|
|
12
|
+
queued: number
|
|
13
|
+
width: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatTime(ms: number): string {
|
|
17
|
+
return new Date(ms).toLocaleTimeString()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function NotificationLog({ notifications, queued, width }: NotificationLogProps) {
|
|
21
|
+
const maxVisible = 8
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Box flexDirection="column" marginTop={1}>
|
|
25
|
+
<Box marginBottom={0}>
|
|
26
|
+
<Text bold color="white"> Notifications </Text>
|
|
27
|
+
{queued > 0 && (
|
|
28
|
+
<Text color="yellow"> ({queued} queued, releasing at overtime…)</Text>
|
|
29
|
+
)}
|
|
30
|
+
{queued === 0 && notifications.length === 0 && (
|
|
31
|
+
<Text color="gray"> (will appear when timer ends)</Text>
|
|
32
|
+
)}
|
|
33
|
+
</Box>
|
|
34
|
+
|
|
35
|
+
<Box
|
|
36
|
+
flexDirection="column"
|
|
37
|
+
borderStyle="single"
|
|
38
|
+
borderColor="gray"
|
|
39
|
+
paddingX={1}
|
|
40
|
+
width={width - 2}
|
|
41
|
+
>
|
|
42
|
+
{notifications.length === 0 ? (
|
|
43
|
+
<Text color="gray" dimColor> —</Text>
|
|
44
|
+
) : (
|
|
45
|
+
notifications.slice(-maxVisible).map((entry, i) => {
|
|
46
|
+
const n = entry.notification as any
|
|
47
|
+
const title = n?.title ?? n?.message ?? JSON.stringify(n).slice(0, 60)
|
|
48
|
+
const time = formatTime(entry.releasedAt)
|
|
49
|
+
return (
|
|
50
|
+
<Box key={i}>
|
|
51
|
+
<Text color="gray">{time} </Text>
|
|
52
|
+
<Text>{title}</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
)}
|
|
57
|
+
</Box>
|
|
58
|
+
</Box>
|
|
59
|
+
)
|
|
60
|
+
}
|