bestviber 0.2.0 → 0.3.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/package.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/format.mjs +4 -0
- package/src/hooks.mjs +45 -17
- package/src/tracker.mjs +28 -23
- package/src/tracker.test.mjs +37 -91
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bestviber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Focus timer and focus analytics for you and your AI agents. Start, pause and finish sessions, and get daily, weekly and monthly stats from the terminal.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"focus",
|
package/src/commands.mjs
CHANGED
package/src/format.mjs
CHANGED
|
@@ -41,6 +41,10 @@ export function stats(stats) {
|
|
|
41
41
|
lines.push(`${title} (${range.tz})`)
|
|
42
42
|
lines.push('')
|
|
43
43
|
lines.push(` focused ${duration(stats.totalMs)}`)
|
|
44
|
+
if (stats.parallelMs > 0)
|
|
45
|
+
lines.push(
|
|
46
|
+
` in parallel ${duration(stats.parallelMs)} (up to ${stats.maxParallel} sessions at once; ${duration(stats.sessionTimeMs)} of session time)`,
|
|
47
|
+
)
|
|
44
48
|
lines.push(` sessions ${stats.sessionCount}`)
|
|
45
49
|
if (stats.sessionCount) {
|
|
46
50
|
lines.push(` average ${duration(stats.averageSessionMs)}`)
|
package/src/hooks.mjs
CHANGED
|
@@ -87,22 +87,42 @@ export async function runHookWorker() {
|
|
|
87
87
|
const e = JSON.parse(process.env.BESTVIBER_HOOK_EVENT || '{}')
|
|
88
88
|
const event = trackerEvent(e)
|
|
89
89
|
if (!event || !(await token())) return
|
|
90
|
+
// Each Claude Code window has its own focus session, so windows on
|
|
91
|
+
// different repositories record in parallel.
|
|
92
|
+
const agentSession = String(e.session_id || 'default')
|
|
90
93
|
await withLock(async () => {
|
|
91
|
-
const
|
|
94
|
+
const all = await readStates()
|
|
95
|
+
const state = all[agentSession] ?? EMPTY_STATE
|
|
92
96
|
const project = detectProject(e.cwd || process.cwd())
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
:
|
|
99
|
-
|
|
97
|
+
/** @type {import('./tracker.mjs').OpenSession} */
|
|
98
|
+
let open = null
|
|
99
|
+
if (event === 'reply')
|
|
100
|
+
open = state.sessionId
|
|
101
|
+
? { id: state.sessionId, status: 'active', project: state.project }
|
|
102
|
+
: null
|
|
103
|
+
else if (state.sessionId) {
|
|
104
|
+
const list = await call('list_open_sessions')
|
|
105
|
+
open =
|
|
106
|
+
(list ?? []).find(
|
|
107
|
+
(/** @type {any} */ s) => s.id === state.sessionId,
|
|
108
|
+
) ?? null
|
|
109
|
+
}
|
|
110
|
+
const result = decide({
|
|
111
|
+
state,
|
|
112
|
+
open,
|
|
113
|
+
event,
|
|
114
|
+
now: new Date(),
|
|
115
|
+
project,
|
|
116
|
+
agentSession,
|
|
117
|
+
})
|
|
100
118
|
const next = { ...result.state }
|
|
101
119
|
for (const c of result.calls) {
|
|
102
120
|
const res = await call(c.name, c.input)
|
|
103
121
|
if (c.name === 'start_focus_session') next.sessionId = res?.id ?? null
|
|
104
122
|
}
|
|
105
|
-
|
|
123
|
+
if (next.sessionId || next.lastActivity) all[agentSession] = next
|
|
124
|
+
else delete all[agentSession]
|
|
125
|
+
await writeStates(all)
|
|
106
126
|
})
|
|
107
127
|
} catch (err) {
|
|
108
128
|
await log(String(/** @type {Error} */ (err)?.stack ?? err))
|
|
@@ -130,20 +150,28 @@ function detectProject(/** @type {string} */ cwd) {
|
|
|
130
150
|
})
|
|
131
151
|
}
|
|
132
152
|
|
|
133
|
-
|
|
153
|
+
/** @returns {Promise<Record<string, import('./tracker.mjs').TrackerState>>} */
|
|
154
|
+
async function readStates() {
|
|
134
155
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
...JSON.parse(await readFile(statePath(), 'utf8')),
|
|
138
|
-
}
|
|
156
|
+
const data = JSON.parse(await readFile(statePath(), 'utf8'))
|
|
157
|
+
return data && typeof data.sessions === 'object' ? data.sessions : {}
|
|
139
158
|
} catch {
|
|
140
|
-
return
|
|
159
|
+
return {}
|
|
141
160
|
}
|
|
142
161
|
}
|
|
143
162
|
|
|
144
|
-
async function
|
|
163
|
+
async function writeStates(
|
|
164
|
+
/** @type {Record<string, import('./tracker.mjs').TrackerState>} */ sessions,
|
|
165
|
+
) {
|
|
166
|
+
// Forget windows quiet for two days: they closed without an exit event.
|
|
167
|
+
const cutoff = Date.now() - 2 * 86_400_000
|
|
168
|
+
for (const [k, v] of Object.entries(sessions))
|
|
169
|
+
if (!v.lastActivity || Date.parse(v.lastActivity) < cutoff)
|
|
170
|
+
delete sessions[k]
|
|
145
171
|
await mkdir(stateDir(), { recursive: true, mode: 0o700 })
|
|
146
|
-
await writeFile(statePath(), JSON.stringify(
|
|
172
|
+
await writeFile(statePath(), JSON.stringify({ sessions }) + '\n', {
|
|
173
|
+
mode: 0o600,
|
|
174
|
+
})
|
|
147
175
|
}
|
|
148
176
|
|
|
149
177
|
export async function clearTrackingState() {
|
package/src/tracker.mjs
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
// hook runner (hooks.mjs) does the I/O; the tests drive this directly.
|
|
6
6
|
//
|
|
7
7
|
// Rules (agreed with the product owner):
|
|
8
|
-
// -
|
|
9
|
-
//
|
|
8
|
+
// - Each agent session (a Claude Code window) has its own focus session, so
|
|
9
|
+
// several windows record in parallel.
|
|
10
|
+
// - A prompt, or Claude Code starting, makes sure that window's session is
|
|
11
|
+
// running for the current project; a project change closes the old one.
|
|
10
12
|
// - Claude finishing a reply counts as activity; no API call is needed.
|
|
11
13
|
// - Quiet under GRACE (reading a reply) still counts as focus.
|
|
12
14
|
// - Quiet from GRACE up to FINISH is recorded as a pause in the same session.
|
|
13
15
|
// - Quiet of FINISH or more ends the session when the quiet began; the next
|
|
14
16
|
// prompt starts a new one.
|
|
15
|
-
// -
|
|
17
|
+
// - Every call names its session by id, so sessions started by hand or by
|
|
18
|
+
// other windows are never touched.
|
|
16
19
|
|
|
17
20
|
export const GRACE_MS = 2 * 60_000
|
|
18
21
|
export const FINISH_MS = 10 * 60_000
|
|
@@ -32,47 +35,49 @@ export const EMPTY_STATE = {
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
|
-
* @param {{ state: TrackerState, open: OpenSession, event: TrackerEvent, now: Date, project: string }} input
|
|
38
|
+
* @param {{ state: TrackerState, open: OpenSession, event: TrackerEvent, now: Date, project: string, agentSession: string }} input
|
|
39
|
+
* `state` and `open` are this agent session's own: `open` is its focus
|
|
40
|
+
* session if still running or paused, else null.
|
|
36
41
|
* @returns {{ calls: Array<Call>, state: TrackerState }}
|
|
37
42
|
*/
|
|
38
|
-
export function decide({ state, open, event, now, project }) {
|
|
43
|
+
export function decide({ state, open, event, now, project, agentSession }) {
|
|
39
44
|
const nowIso = now.toISOString()
|
|
40
45
|
const last = state.lastActivity ? new Date(state.lastActivity) : null
|
|
41
46
|
const gap = last ? now.getTime() - last.getTime() : 0
|
|
42
|
-
const
|
|
47
|
+
const id = state.sessionId
|
|
48
|
+
const ours = open !== null && id !== null && open.id === id
|
|
43
49
|
/** When the work in the open session really stopped. */
|
|
44
50
|
const endedAt = last && gap >= GRACE_MS ? state.lastActivity : nowIso
|
|
45
51
|
|
|
46
|
-
// Someone else's session is open: stay out of its way.
|
|
47
|
-
if (open && !ours)
|
|
48
|
-
return {
|
|
49
|
-
calls: [],
|
|
50
|
-
state: { sessionId: null, project: null, lastActivity: nowIso },
|
|
51
|
-
}
|
|
52
|
-
|
|
53
52
|
if (event === 'reply')
|
|
54
53
|
return { calls: [], state: { ...state, lastActivity: nowIso } }
|
|
55
54
|
|
|
56
|
-
if (event === 'end')
|
|
57
|
-
if (!ours) return { calls: [], state: EMPTY_STATE }
|
|
55
|
+
if (event === 'end')
|
|
58
56
|
return {
|
|
59
|
-
calls:
|
|
57
|
+
calls: ours
|
|
58
|
+
? [{ name: 'finish_focus_session', input: { id, at: endedAt } }]
|
|
59
|
+
: [],
|
|
60
60
|
state: EMPTY_STATE,
|
|
61
61
|
}
|
|
62
|
-
}
|
|
63
62
|
|
|
64
|
-
// 'start' or 'prompt': make sure this
|
|
63
|
+
// 'start' or 'prompt': make sure this window's session is running.
|
|
65
64
|
/** @type {Array<Call>} */
|
|
66
65
|
const calls = []
|
|
67
|
-
const start = {
|
|
66
|
+
const start = {
|
|
67
|
+
name: 'start_focus_session',
|
|
68
|
+
input: { project, agentSession },
|
|
69
|
+
}
|
|
68
70
|
|
|
69
71
|
if (!ours) calls.push(start)
|
|
70
72
|
else if (open.project !== project || gap >= FINISH_MS) {
|
|
71
|
-
calls.push(
|
|
73
|
+
calls.push(
|
|
74
|
+
{ name: 'finish_focus_session', input: { id, at: endedAt } },
|
|
75
|
+
start,
|
|
76
|
+
)
|
|
72
77
|
} else if (gap >= GRACE_MS) {
|
|
73
78
|
calls.push(
|
|
74
|
-
{ name: 'pause_focus_session', input: { at: state.lastActivity } },
|
|
75
|
-
{ name: 'resume_focus_session', input: {} },
|
|
79
|
+
{ name: 'pause_focus_session', input: { id, at: state.lastActivity } },
|
|
80
|
+
{ name: 'resume_focus_session', input: { id } },
|
|
76
81
|
)
|
|
77
82
|
}
|
|
78
83
|
|
|
@@ -81,7 +86,7 @@ export function decide({ state, open, event, now, project }) {
|
|
|
81
86
|
// A new session's id comes back from start_focus_session; the runner
|
|
82
87
|
// fills it in.
|
|
83
88
|
state: {
|
|
84
|
-
sessionId: calls.includes(start) ? null :
|
|
89
|
+
sessionId: calls.includes(start) ? null : id,
|
|
85
90
|
project,
|
|
86
91
|
lastActivity: nowIso,
|
|
87
92
|
},
|
package/src/tracker.test.mjs
CHANGED
|
@@ -18,132 +18,78 @@ const tracking = (lastHm, overrides = {}) => ({
|
|
|
18
18
|
})
|
|
19
19
|
|
|
20
20
|
describe('automatic tracking', () => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
])
|
|
21
|
+
const W = 'claude-window-1'
|
|
22
|
+
const go = (state, open, event, hm, project = P) =>
|
|
23
|
+
decide({ state, open, event, now: at(hm), project, agentSession: W })
|
|
24
|
+
const start = (project = P) => ({
|
|
25
|
+
name: 'start_focus_session',
|
|
26
|
+
input: { project, agentSession: W },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('the first prompt starts a session for the project and this window', () => {
|
|
30
|
+
const r = go(EMPTY_STATE, null, 'prompt', '09:00')
|
|
31
|
+
expect(r.calls).toEqual([start()])
|
|
32
32
|
expect(r.state.lastActivity).toBe(iso('09:00'))
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
test('a quick next prompt changes nothing', () => {
|
|
36
|
-
const r =
|
|
37
|
-
state: tracking('09:00'),
|
|
38
|
-
open: mine(),
|
|
39
|
-
event: 'prompt',
|
|
40
|
-
now: at('09:01'),
|
|
41
|
-
project: P,
|
|
42
|
-
})
|
|
36
|
+
const r = go(tracking('09:00'), mine(), 'prompt', '09:01')
|
|
43
37
|
expect(r.calls).toEqual([])
|
|
44
38
|
expect(r.state.sessionId).toBe('s1')
|
|
45
39
|
})
|
|
46
40
|
|
|
47
41
|
test('quiet between 2 and 10 minutes becomes a pause in the same session', () => {
|
|
48
|
-
const r =
|
|
49
|
-
state: tracking('09:00'),
|
|
50
|
-
open: mine(),
|
|
51
|
-
event: 'prompt',
|
|
52
|
-
now: at('09:06'),
|
|
53
|
-
project: P,
|
|
54
|
-
})
|
|
42
|
+
const r = go(tracking('09:00'), mine(), 'prompt', '09:06')
|
|
55
43
|
expect(r.calls).toEqual([
|
|
56
|
-
{ name: 'pause_focus_session', input: { at: iso('09:00') } },
|
|
57
|
-
{ name: 'resume_focus_session', input: {} },
|
|
44
|
+
{ name: 'pause_focus_session', input: { id: 's1', at: iso('09:00') } },
|
|
45
|
+
{ name: 'resume_focus_session', input: { id: 's1' } },
|
|
58
46
|
])
|
|
59
47
|
expect(r.state.sessionId).toBe('s1')
|
|
60
48
|
})
|
|
61
49
|
|
|
62
50
|
test('quiet of 10 minutes or more ends the session when the quiet began', () => {
|
|
63
|
-
const r =
|
|
64
|
-
state: tracking('09:00'),
|
|
65
|
-
open: mine(),
|
|
66
|
-
event: 'prompt',
|
|
67
|
-
now: at('09:30'),
|
|
68
|
-
project: P,
|
|
69
|
-
})
|
|
51
|
+
const r = go(tracking('09:00'), mine(), 'prompt', '09:30')
|
|
70
52
|
expect(r.calls).toEqual([
|
|
71
|
-
{ name: 'finish_focus_session', input: { at: iso('09:00') } },
|
|
72
|
-
|
|
53
|
+
{ name: 'finish_focus_session', input: { id: 's1', at: iso('09:00') } },
|
|
54
|
+
start(),
|
|
73
55
|
])
|
|
74
56
|
expect(r.state.sessionId).toBeNull()
|
|
75
57
|
})
|
|
76
58
|
|
|
77
|
-
test('switching repositories closes
|
|
78
|
-
const r =
|
|
79
|
-
state: tracking('09:00'),
|
|
80
|
-
open: mine(),
|
|
81
|
-
event: 'prompt',
|
|
82
|
-
now: at('09:01'),
|
|
83
|
-
project: 'amar/website',
|
|
84
|
-
})
|
|
59
|
+
test('switching repositories in a window closes its old project and starts the new one', () => {
|
|
60
|
+
const r = go(tracking('09:00'), mine(), 'prompt', '09:01', 'amar/website')
|
|
85
61
|
expect(r.calls).toEqual([
|
|
86
|
-
{ name: 'finish_focus_session', input: { at: iso('09:01') } },
|
|
87
|
-
|
|
62
|
+
{ name: 'finish_focus_session', input: { id: 's1', at: iso('09:01') } },
|
|
63
|
+
start('amar/website'),
|
|
88
64
|
])
|
|
89
65
|
})
|
|
90
66
|
|
|
91
67
|
test('a finished reply is activity, with no API call', () => {
|
|
92
|
-
const r =
|
|
93
|
-
state: tracking('09:00'),
|
|
94
|
-
open: mine(),
|
|
95
|
-
event: 'reply',
|
|
96
|
-
now: at('09:04'),
|
|
97
|
-
project: P,
|
|
98
|
-
})
|
|
68
|
+
const r = go(tracking('09:00'), mine(), 'reply', '09:04')
|
|
99
69
|
expect(r.calls).toEqual([])
|
|
100
70
|
expect(r.state.lastActivity).toBe(iso('09:04'))
|
|
101
71
|
})
|
|
102
72
|
|
|
103
73
|
test('Claude Code exiting finishes the session, at the last activity if it was quiet', () => {
|
|
104
|
-
expect(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
project: P,
|
|
111
|
-
}).calls,
|
|
112
|
-
).toEqual([{ name: 'finish_focus_session', input: { at: iso('09:00') } }])
|
|
113
|
-
expect(
|
|
114
|
-
decide({
|
|
115
|
-
state: tracking('09:00'),
|
|
116
|
-
open: mine(),
|
|
117
|
-
event: 'end',
|
|
118
|
-
now: at('09:01'),
|
|
119
|
-
project: P,
|
|
120
|
-
}).calls,
|
|
121
|
-
).toEqual([{ name: 'finish_focus_session', input: { at: iso('09:01') } }])
|
|
74
|
+
expect(go(tracking('09:00'), mine(), 'end', '09:20').calls).toEqual([
|
|
75
|
+
{ name: 'finish_focus_session', input: { id: 's1', at: iso('09:00') } },
|
|
76
|
+
])
|
|
77
|
+
expect(go(tracking('09:00'), mine(), 'end', '09:01').calls).toEqual([
|
|
78
|
+
{ name: 'finish_focus_session', input: { id: 's1', at: iso('09:01') } },
|
|
79
|
+
])
|
|
122
80
|
})
|
|
123
81
|
|
|
124
|
-
test('
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
project: P,
|
|
131
|
-
})
|
|
132
|
-
expect(r.calls).toEqual([])
|
|
133
|
-
expect(r.state.sessionId).toBeNull()
|
|
82
|
+
test("another window's session is never touched: every call names its own id", () => {
|
|
83
|
+
// Window 2 starts while window 1's session s1 is open elsewhere.
|
|
84
|
+
const r = go(EMPTY_STATE, null, 'prompt', '09:00', 'amar/website')
|
|
85
|
+
expect(r.calls).toEqual([start('amar/website')])
|
|
86
|
+
for (const c of go(tracking('09:00'), mine(), 'prompt', '09:30').calls)
|
|
87
|
+
if (c.name !== 'start_focus_session') expect(c.input.id).toBe('s1')
|
|
134
88
|
})
|
|
135
89
|
|
|
136
90
|
test('if our session was closed elsewhere, the next prompt starts a new one', () => {
|
|
137
|
-
const r =
|
|
138
|
-
|
|
139
|
-
open: null,
|
|
140
|
-
event: 'prompt',
|
|
141
|
-
now: at('09:01'),
|
|
142
|
-
project: P,
|
|
143
|
-
})
|
|
144
|
-
expect(r.calls).toEqual([
|
|
145
|
-
{ name: 'start_focus_session', input: { project: P } },
|
|
146
|
-
])
|
|
91
|
+
const r = go(tracking('09:00'), null, 'prompt', '09:01')
|
|
92
|
+
expect(r.calls).toEqual([start()])
|
|
147
93
|
})
|
|
148
94
|
})
|
|
149
95
|
|