bestviber 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bestviber",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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
@@ -326,7 +326,7 @@ export const commands = {
326
326
  hook: {
327
327
  summary: 'Claude Code hook entry point',
328
328
  actions: [
329
- 'get_active_session',
329
+ 'list_open_sessions',
330
330
  'start_focus_session',
331
331
  'pause_focus_session',
332
332
  'resume_focus_session',
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 state = await readState()
94
+ const all = await readStates()
95
+ const state = all[agentSession] ?? EMPTY_STATE
92
96
  const project = detectProject(e.cwd || process.cwd())
93
- const open =
94
- event === 'reply'
95
- ? state.sessionId
96
- ? { id: state.sessionId, status: 'active', project: state.project }
97
- : null
98
- : await call('get_active_session')
99
- const result = decide({ state, open, event, now: new Date(), project })
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
- await writeState(next)
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
- async function readState() {
153
+ /** @returns {Promise<Record<string, import('./tracker.mjs').TrackerState>>} */
154
+ async function readStates() {
134
155
  try {
135
- return {
136
- ...EMPTY_STATE,
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 EMPTY_STATE
159
+ return {}
141
160
  }
142
161
  }
143
162
 
144
- async function writeState(/** @type {object} */ state) {
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(state) + '\n', { mode: 0o600 })
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
- // - A prompt, or Claude Code starting, makes sure a session is running for
9
- // the current project. A different project's session is closed first.
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
- // - A session someone started by hand (CLI, app, agent) is left alone.
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 ours = open !== null && open.id === state.sessionId
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: [{ name: 'finish_focus_session', input: { at: endedAt } }],
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 project's session is running.
63
+ // 'start' or 'prompt': make sure this window's session is running.
65
64
  /** @type {Array<Call>} */
66
65
  const calls = []
67
- const start = { name: 'start_focus_session', input: { project } }
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({ name: 'finish_focus_session', input: { at: endedAt } }, start)
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 : state.sessionId,
89
+ sessionId: calls.includes(start) ? null : id,
85
90
  project,
86
91
  lastActivity: nowIso,
87
92
  },
@@ -18,132 +18,78 @@ const tracking = (lastHm, overrides = {}) => ({
18
18
  })
19
19
 
20
20
  describe('automatic tracking', () => {
21
- test('the first prompt starts a session for the project', () => {
22
- const r = decide({
23
- state: EMPTY_STATE,
24
- open: null,
25
- event: 'prompt',
26
- now: at('09:00'),
27
- project: P,
28
- })
29
- expect(r.calls).toEqual([
30
- { name: 'start_focus_session', input: { project: P } },
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 = decide({
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 = decide({
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 = decide({
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
- { name: 'start_focus_session', input: { project: P } },
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 the old project and starts the new one', () => {
78
- const r = decide({
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
- { name: 'start_focus_session', input: { project: 'amar/website' } },
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 = decide({
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
- decide({
106
- state: tracking('09:00'),
107
- open: mine(),
108
- event: 'end',
109
- now: at('09:20'),
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('a session someone started by hand is left alone', () => {
125
- const r = decide({
126
- state: EMPTY_STATE,
127
- open: { id: 'manual', status: 'active', project: null },
128
- event: 'prompt',
129
- now: at('09:00'),
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 = decide({
138
- state: tracking('09:00'),
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