bestviber 0.1.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/README.md CHANGED
@@ -2,12 +2,28 @@
2
2
 
3
3
  Focus timer and focus analytics for you and your AI agents.
4
4
 
5
+ ## Claude Code, in two commands
6
+
5
7
  ```sh
6
8
  npm install -g bestviber
7
- bestviber login # approve a code in your browser
9
+ bestviber login
10
+ ```
11
+
12
+ `login` opens your browser to approve a short code, installs the agent skill,
13
+ and asks whether to track your Claude Code sessions automatically. Say yes and
14
+ there is nothing else to do: every Claude Code session is timed and filed
15
+ under the repository you work in. Quiet stretches are recorded as pauses, and
16
+ ten quiet minutes end the session. Then ask your agent "how was my week?", or
17
+ run `bestviber week`.
18
+
19
+ `bestviber logout` signs out and removes the tracking.
20
+
21
+ ## By hand
22
+
23
+ ```sh
8
24
  bestviber start # start a focus session
9
25
  bestviber stop --note "wrote the launch post"
10
- bestviber week # this week: total, by day, by hour, by tag, vs last week
26
+ bestviber week # this week: total, by day, by hour, by tag and project
11
27
  bestviber insights # peak hours, best weekday, streaks, trends
12
28
  ```
13
29
 
@@ -15,11 +31,13 @@ Every command takes `--json` for scripts and agents. `bestviber tools` lists
15
31
  every action the server offers and `bestviber run <action>` runs any of them,
16
32
  so new features work without updating the CLI.
17
33
 
18
- ## Agents
34
+ ## Other agents
19
35
 
20
- - `bestviber skill install` adds the bestviber skill for Claude Code and
21
- other agents that read `~/.agents/skills`.
36
+ - The skill goes to `~/.claude/skills/bestviber`, and also to
37
+ `~/.agents/skills/bestviber` if you have an `~/.agents` folder. Reinstall or
38
+ update it with `bestviber skill install`.
22
39
  - `bestviber mcp` prints how to connect any MCP client to
23
- `https://www.bestviber.com/api/mcp`.
40
+ `https://www.bestviber.com/api/mcp`. Sign in first: the setup line uses
41
+ your token.
24
42
 
25
43
  Free. Same account as [bestviber.com](https://www.bestviber.com).
package/bin/bestviber.mjs CHANGED
@@ -10,9 +10,9 @@ const pkg = JSON.parse(
10
10
 
11
11
  function help() {
12
12
  const width = Math.max(...Object.keys(commands).map((c) => c.length))
13
- const lines = Object.entries(commands).map(
14
- ([name, c]) => ` ${name.padEnd(width)} ${c.summary}`,
15
- )
13
+ const lines = Object.entries(commands)
14
+ .filter(([, c]) => !c.hidden)
15
+ .map(([name, c]) => ` ${name.padEnd(width)} ${c.summary}`)
16
16
  return `bestviber ${pkg.version}: focus timer and focus analytics for you and your agents
17
17
 
18
18
  Usage: bestviber <command> [options]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bestviber",
3
- "version": "0.1.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
@@ -18,6 +18,15 @@ import {
18
18
  writeConfig,
19
19
  } from './client.mjs'
20
20
  import { clock, duration, elapsed, stats } from './format.mjs'
21
+ import {
22
+ clearTrackingState,
23
+ hasClaudeCode,
24
+ hooksInstalled,
25
+ installHooks,
26
+ launchHook,
27
+ runHookWorker,
28
+ uninstallHooks,
29
+ } from './hooks.mjs'
21
30
 
22
31
  /**
23
32
  * @typedef {{ flags: Record<string, any>, args: string[], json: boolean }} Ctx
@@ -25,10 +34,80 @@ import { clock, duration, elapsed, stats } from './format.mjs'
25
34
  * summary: string,
26
35
  * usage?: string,
27
36
  * actions: string[],
37
+ * hidden?: boolean,
28
38
  * run: (ctx: Ctx) => Promise<unknown>,
29
39
  * }} Command
30
40
  */
31
41
 
42
+ /** Download the agent skill into the folders agents read. */
43
+ async function installSkill(/** @type {string | undefined} */ dir) {
44
+ // ~/.claude/skills for Claude Code; ~/.agents/skills is the shared folder
45
+ // other agents read, when it exists.
46
+ const dirs = dir
47
+ ? [dir]
48
+ : [
49
+ join(homedir(), '.claude', 'skills'),
50
+ ...(existsSync(join(homedir(), '.agents'))
51
+ ? [join(homedir(), '.agents', 'skills')]
52
+ : []),
53
+ ]
54
+ const files = await Promise.all(
55
+ ['SKILL.md', 'reference.md'].map(async (name) => {
56
+ const res = await fetch(`${await baseUrl()}/skill/${name}`)
57
+ if (!res.ok)
58
+ throw new CliError(`Could not download ${name} (HTTP ${res.status}).`)
59
+ return { name, body: await res.text() }
60
+ }),
61
+ )
62
+ const written = []
63
+ for (const d of dirs) {
64
+ const target = join(d, 'bestviber')
65
+ await mkdir(target, { recursive: true })
66
+ for (const f of files) await writeFile(join(target, f.name), f.body)
67
+ written.push(target)
68
+ }
69
+ return written
70
+ }
71
+
72
+ /** Yes/no on a terminal; with no terminal (an agent ran us), the default. */
73
+ async function confirm(/** @type {string} */ question, fallback = true) {
74
+ if (!process.stdin.isTTY || !process.stderr.isTTY) return fallback
75
+ const { createInterface } = await import('node:readline/promises')
76
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
77
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase()
78
+ rl.close()
79
+ return answer === '' ? fallback : answer.startsWith('y')
80
+ }
81
+
82
+ /** After sign-in: the skill, and automatic tracking for Claude Code. */
83
+ async function setUpAgents() {
84
+ const lines = []
85
+ try {
86
+ await installSkill(undefined)
87
+ lines.push('Installed the agent skill.')
88
+ } catch (err) {
89
+ lines.push(`Skipped the agent skill: ${/** @type {Error} */ (err).message}`)
90
+ }
91
+ if (!hasClaudeCode()) return lines
92
+ const ask = process.stdin.isTTY && process.stderr.isTTY
93
+ if (
94
+ await confirm(
95
+ '\nTrack your Claude Code sessions automatically? Each session is filed under the repository you work in.',
96
+ )
97
+ ) {
98
+ await installHooks()
99
+ lines.push(
100
+ 'Claude Code sessions are now tracked automatically, by project.' +
101
+ (ask ? '' : ' (Turn it off with `bestviber tracking off`.)'),
102
+ )
103
+ } else {
104
+ lines.push(
105
+ 'Automatic tracking is off. Turn it on with `bestviber tracking on`.',
106
+ )
107
+ }
108
+ return lines
109
+ }
110
+
32
111
  const sleep = (/** @type {number} */ ms) =>
33
112
  new Promise((r) => setTimeout(r, ms))
34
113
 
@@ -121,10 +200,11 @@ export const commands = {
121
200
  email: me?.user?.email,
122
201
  })
123
202
  process.stderr.write(' approved.\n')
203
+ const setup = await setUpAgents()
124
204
  return out(
125
205
  ctx,
126
- { ok: true, email: me?.user?.email },
127
- `Signed in as ${me?.user?.email ?? 'you'}.`,
206
+ { ok: true, email: me?.user?.email, setup },
207
+ [`Signed in as ${me?.user?.email ?? 'you'}.`, ...setup].join('\n'),
128
208
  )
129
209
  }
130
210
  const err = res.data?.error
@@ -152,7 +232,15 @@ export const commands = {
152
232
  if (await token())
153
233
  await request('/api/auth/sign-out', { body: {} }).catch(() => {})
154
234
  await clearConfig()
155
- return out(ctx, { ok: true }, 'Signed out.')
235
+ const removed = await uninstallHooks().catch(() => false)
236
+ await clearTrackingState()
237
+ return out(
238
+ ctx,
239
+ { ok: true, trackingRemoved: removed },
240
+ removed
241
+ ? 'Signed out, and stopped tracking Claude Code sessions.'
242
+ : 'Signed out.',
243
+ )
156
244
  },
157
245
  },
158
246
 
@@ -196,6 +284,12 @@ export const commands = {
196
284
  }
197
285
  if (ctx.json) return config
198
286
  return [
287
+ ...((await token())
288
+ ? []
289
+ : [
290
+ 'Not signed in yet: run `bestviber login` first, or the token below is empty.',
291
+ '',
292
+ ]),
199
293
  'Claude Code:',
200
294
  ` claude mcp add --transport http bestviber ${url} \\`,
201
295
  ' --header "Authorization: Bearer $(bestviber token)" \\',
@@ -217,33 +311,9 @@ export const commands = {
217
311
  async run(ctx) {
218
312
  if (ctx.args[0] !== 'install')
219
313
  throw new CliError('Usage: bestviber skill install [--dir <folder>]')
220
- // ~/.claude/skills for Claude Code; ~/.agents/skills is the shared
221
- // folder other agents read, when it exists.
222
- const dirs = ctx.flags.dir
223
- ? [String(ctx.flags.dir)]
224
- : [
225
- join(homedir(), '.claude', 'skills'),
226
- ...(existsSync(join(homedir(), '.agents'))
227
- ? [join(homedir(), '.agents', 'skills')]
228
- : []),
229
- ]
230
- const files = await Promise.all(
231
- ['SKILL.md', 'reference.md'].map(async (name) => {
232
- const res = await fetch(`${await baseUrl()}/skill/${name}`)
233
- if (!res.ok)
234
- throw new CliError(
235
- `Could not download ${name} (HTTP ${res.status}).`,
236
- )
237
- return { name, body: await res.text() }
238
- }),
314
+ const written = await installSkill(
315
+ ctx.flags.dir ? String(ctx.flags.dir) : undefined,
239
316
  )
240
- const written = []
241
- for (const dir of dirs) {
242
- const target = join(dir, 'bestviber')
243
- await mkdir(target, { recursive: true })
244
- for (const f of files) await writeFile(join(target, f.name), f.body)
245
- written.push(target)
246
- }
247
317
  return out(
248
318
  ctx,
249
319
  { installed: written },
@@ -252,6 +322,56 @@ export const commands = {
252
322
  },
253
323
  },
254
324
 
325
+ // Called by Claude Code, not by people: see hooks.mjs.
326
+ hook: {
327
+ summary: 'Claude Code hook entry point',
328
+ actions: [
329
+ 'list_open_sessions',
330
+ 'start_focus_session',
331
+ 'pause_focus_session',
332
+ 'resume_focus_session',
333
+ 'finish_focus_session',
334
+ ],
335
+ hidden: true,
336
+ async run(ctx) {
337
+ if (ctx.flags.run) await runHookWorker()
338
+ else await launchHook()
339
+ return undefined
340
+ },
341
+ },
342
+
343
+ tracking: {
344
+ summary: 'Automatic tracking of Claude Code sessions: on, off or status',
345
+ usage: 'tracking [on | off]',
346
+ actions: [],
347
+ hidden: true,
348
+ async run(ctx) {
349
+ const sub = ctx.args[0]
350
+ if (sub === 'on') {
351
+ if (!(await token()))
352
+ throw new CliError('Not signed in. Run `bestviber login` first.', 2)
353
+ const r = await installHooks()
354
+ return out(
355
+ ctx,
356
+ { on: true, ...r },
357
+ 'Claude Code sessions are tracked automatically.',
358
+ )
359
+ }
360
+ if (sub === 'off') {
361
+ await uninstallHooks()
362
+ await clearTrackingState()
363
+ return out(ctx, { on: false }, 'Automatic tracking is off.')
364
+ }
365
+ if (sub) throw new CliError('Usage: bestviber tracking [on | off]')
366
+ const on = await hooksInstalled()
367
+ return out(
368
+ ctx,
369
+ { on },
370
+ on ? 'Automatic tracking is on.' : 'Automatic tracking is off.',
371
+ )
372
+ },
373
+ },
374
+
255
375
  // ───── timer ─────
256
376
  start: {
257
377
  summary: 'Start a focus session',
package/src/format.mjs CHANGED
@@ -83,5 +83,15 @@ export function stats(stats) {
83
83
  for (const t of stats.byTag)
84
84
  lines.push(` ${t.tag.padEnd(16)} ${duration(t.ms)}`)
85
85
  }
86
+
87
+ if (stats.byProject?.length) {
88
+ lines.push('', ' by project')
89
+ const width = Math.max(
90
+ 16,
91
+ ...stats.byProject.map((/** @type {any} */ p) => p.project.length),
92
+ )
93
+ for (const p of stats.byProject)
94
+ lines.push(` ${p.project.padEnd(width)} ${duration(p.ms)}`)
95
+ }
86
96
  return lines.join('\n')
87
97
  }
package/src/hooks.mjs ADDED
@@ -0,0 +1,334 @@
1
+ // Claude Code hooks: install them, and run them.
2
+ //
3
+ // Claude Code runs `bestviber hook` on SessionStart, UserPromptSubmit, Stop
4
+ // and SessionEnd. That command must be instant and silent (stdout on
5
+ // SessionStart and UserPromptSubmit is added to Claude's context, and
6
+ // SessionEnd gets about 1.5 s before exit), so it only hands the event to a
7
+ // detached worker and exits. The worker talks to the server.
8
+
9
+ import { execFileSync, spawn } from 'node:child_process'
10
+ import { existsSync } from 'node:fs'
11
+ import {
12
+ appendFile,
13
+ copyFile,
14
+ mkdir,
15
+ readFile,
16
+ rename,
17
+ rm,
18
+ stat,
19
+ writeFile,
20
+ } from 'node:fs/promises'
21
+ import { homedir } from 'node:os'
22
+ import { dirname, join } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { call, configPath, token } from './client.mjs'
25
+ import { EMPTY_STATE, decide, projectName } from './tracker.mjs'
26
+
27
+ const BIN = fileURLToPath(new URL('../bin/bestviber.mjs', import.meta.url))
28
+ const stateDir = () => dirname(configPath())
29
+ const statePath = () => join(stateDir(), 'tracking.json')
30
+ const logPath = () => join(stateDir(), 'hook.log')
31
+ const lockPath = () => join(stateDir(), 'tracking.lock')
32
+
33
+ // ───── the hook command ─────
34
+
35
+ /** Read the event from stdin and hand it to a detached worker. Never prints. */
36
+ export async function launchHook() {
37
+ const payload = await readStdin(2000)
38
+ try {
39
+ spawn(process.execPath, [BIN, 'hook', '--run'], {
40
+ detached: true,
41
+ stdio: 'ignore',
42
+ env: { ...process.env, BESTVIBER_HOOK_EVENT: payload },
43
+ }).unref()
44
+ } catch {
45
+ // Tracking is best-effort; never get in the way of Claude Code.
46
+ }
47
+ }
48
+
49
+ function readStdin(/** @type {number} */ ms) {
50
+ return new Promise((resolve) => {
51
+ let data = ''
52
+ const done = () => resolve(data)
53
+ const timer = setTimeout(done, ms)
54
+ process.stdin.setEncoding('utf8')
55
+ process.stdin.on('data', (c) => (data += c))
56
+ process.stdin.on('end', () => {
57
+ clearTimeout(timer)
58
+ done()
59
+ })
60
+ process.stdin.on('error', done)
61
+ })
62
+ }
63
+
64
+ /** Map a Claude Code hook event to a tracker event, or null to ignore it. */
65
+ function trackerEvent(/** @type {any} */ e) {
66
+ switch (e?.hook_event_name) {
67
+ case 'SessionStart':
68
+ return 'start'
69
+ case 'UserPromptSubmit':
70
+ return 'prompt'
71
+ case 'Stop':
72
+ return 'reply'
73
+ case 'SessionEnd':
74
+ // /clear and /resume also end a session id, but the work goes on.
75
+ return ['prompt_input_exit', 'logout', 'other'].includes(e.reason)
76
+ ? 'end'
77
+ : null
78
+ default:
79
+ return null
80
+ }
81
+ }
82
+
83
+ // ───── the worker ─────
84
+
85
+ export async function runHookWorker() {
86
+ try {
87
+ const e = JSON.parse(process.env.BESTVIBER_HOOK_EVENT || '{}')
88
+ const event = trackerEvent(e)
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')
93
+ await withLock(async () => {
94
+ const all = await readStates()
95
+ const state = all[agentSession] ?? EMPTY_STATE
96
+ const project = detectProject(e.cwd || process.cwd())
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
+ })
118
+ const next = { ...result.state }
119
+ for (const c of result.calls) {
120
+ const res = await call(c.name, c.input)
121
+ if (c.name === 'start_focus_session') next.sessionId = res?.id ?? null
122
+ }
123
+ if (next.sessionId || next.lastActivity) all[agentSession] = next
124
+ else delete all[agentSession]
125
+ await writeStates(all)
126
+ })
127
+ } catch (err) {
128
+ await log(String(/** @type {Error} */ (err)?.stack ?? err))
129
+ }
130
+ }
131
+
132
+ function detectProject(/** @type {string} */ cwd) {
133
+ const git = (/** @type {string[]} */ args) => {
134
+ try {
135
+ return (
136
+ execFileSync('git', ['-C', cwd, ...args], {
137
+ encoding: 'utf8',
138
+ stdio: ['ignore', 'pipe', 'ignore'],
139
+ timeout: 1500,
140
+ }).trim() || null
141
+ )
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+ return projectName({
147
+ remote: git(['remote', 'get-url', 'origin']),
148
+ toplevel: git(['rev-parse', '--show-toplevel']),
149
+ cwd,
150
+ })
151
+ }
152
+
153
+ /** @returns {Promise<Record<string, import('./tracker.mjs').TrackerState>>} */
154
+ async function readStates() {
155
+ try {
156
+ const data = JSON.parse(await readFile(statePath(), 'utf8'))
157
+ return data && typeof data.sessions === 'object' ? data.sessions : {}
158
+ } catch {
159
+ return {}
160
+ }
161
+ }
162
+
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]
171
+ await mkdir(stateDir(), { recursive: true, mode: 0o700 })
172
+ await writeFile(statePath(), JSON.stringify({ sessions }) + '\n', {
173
+ mode: 0o600,
174
+ })
175
+ }
176
+
177
+ export async function clearTrackingState() {
178
+ await rm(statePath(), { force: true })
179
+ }
180
+
181
+ /** One worker at a time: a prompt and a reply can land together. */
182
+ async function withLock(/** @type {() => Promise<void>} */ fn) {
183
+ await mkdir(stateDir(), { recursive: true, mode: 0o700 })
184
+ const deadline = Date.now() + 8000
185
+ for (;;) {
186
+ try {
187
+ await mkdir(lockPath())
188
+ break
189
+ } catch {
190
+ // A lock older than 30 s belongs to a worker that died.
191
+ const age = await stat(lockPath())
192
+ .then((s) => Date.now() - s.mtimeMs)
193
+ .catch(() => 0)
194
+ if (age > 30_000) await rm(lockPath(), { recursive: true, force: true })
195
+ else if (Date.now() > deadline) return
196
+ else await new Promise((r) => setTimeout(r, 150))
197
+ }
198
+ }
199
+ try {
200
+ await fn()
201
+ } finally {
202
+ await rm(lockPath(), { recursive: true, force: true })
203
+ }
204
+ }
205
+
206
+ async function log(/** @type {string} */ line) {
207
+ try {
208
+ const size = await stat(logPath())
209
+ .then((s) => s.size)
210
+ .catch(() => 0)
211
+ if (size > 100_000) await rm(logPath(), { force: true })
212
+ await appendFile(logPath(), `${new Date().toISOString()} ${line}\n`)
213
+ } catch {
214
+ // Nowhere left to report to.
215
+ }
216
+ }
217
+
218
+ // ───── installing into Claude Code ─────
219
+
220
+ const claudeDir = () =>
221
+ process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
222
+ const settingsPath = () => join(claudeDir(), 'settings.json')
223
+
224
+ /** Whether Claude Code is set up on this machine. */
225
+ export const hasClaudeCode = () => existsSync(claudeDir())
226
+
227
+ /** The command Claude Code runs, with an absolute path (hooks may not share the shell's PATH). */
228
+ function hookCommand() {
229
+ const q = (/** @type {string} */ s) => `"${s.replace(/(["\\$`])/g, '\\$1')}"`
230
+ let onPath = null
231
+ try {
232
+ onPath = execFileSync('sh', ['-c', 'command -v bestviber'], {
233
+ encoding: 'utf8',
234
+ stdio: ['ignore', 'pipe', 'ignore'],
235
+ }).trim()
236
+ } catch {
237
+ // Not on PATH (for example run through pnpm dlx).
238
+ }
239
+ return onPath ? `${q(onPath)} hook` : `${q(process.execPath)} ${q(BIN)} hook`
240
+ }
241
+
242
+ const isOurs = (/** @type {any} */ h) =>
243
+ typeof h?.command === 'string' &&
244
+ /bestviber(\.mjs)?"?\s+hook$/.test(h.command)
245
+
246
+ /** Remove our hook entries from every event, keeping everything else. */
247
+ function withoutOurs(/** @type {Record<string, any>} */ hooks) {
248
+ /** @type {Record<string, any>} */
249
+ const cleaned = {}
250
+ for (const [event, groups] of Object.entries(hooks ?? {})) {
251
+ if (!Array.isArray(groups)) {
252
+ cleaned[event] = groups
253
+ continue
254
+ }
255
+ const kept = groups
256
+ .map((g) => ({
257
+ ...g,
258
+ hooks: Array.isArray(g?.hooks)
259
+ ? g.hooks.filter((h) => !isOurs(h))
260
+ : g?.hooks,
261
+ }))
262
+ .filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
263
+ if (kept.length) cleaned[event] = kept
264
+ }
265
+ return cleaned
266
+ }
267
+
268
+ async function readSettings() {
269
+ try {
270
+ return JSON.parse(await readFile(settingsPath(), 'utf8'))
271
+ } catch (err) {
272
+ if (/** @type {any} */ (err).code === 'ENOENT') return {}
273
+ throw new Error(
274
+ `Could not read ${settingsPath()} (${/** @type {Error} */ (err).message}). Left it unchanged.`,
275
+ )
276
+ }
277
+ }
278
+
279
+ async function writeSettings(/** @type {object} */ settings) {
280
+ const path = settingsPath()
281
+ await mkdir(dirname(path), { recursive: true })
282
+ if (existsSync(path) && !existsSync(`${path}.bestviber-backup`))
283
+ await copyFile(path, `${path}.bestviber-backup`)
284
+ const tmp = `${path}.bestviber-tmp`
285
+ await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n')
286
+ await rename(tmp, path)
287
+ }
288
+
289
+ export async function installHooks() {
290
+ const settings = await readSettings()
291
+ const command = hookCommand()
292
+ const entry = { type: 'command', command, timeout: 10 }
293
+ const hooks = withoutOurs(settings.hooks)
294
+ /** @type {Array<[string, string | undefined]>} */
295
+ const events = [
296
+ ['SessionStart', undefined],
297
+ ['UserPromptSubmit', undefined],
298
+ ['Stop', undefined],
299
+ ['SessionEnd', 'prompt_input_exit|logout|other'],
300
+ ]
301
+ for (const [event, matcher] of events)
302
+ hooks[event] = [
303
+ ...(hooks[event] ?? []),
304
+ matcher ? { matcher, hooks: [entry] } : { hooks: [entry] },
305
+ ]
306
+ await writeSettings({ ...settings, hooks })
307
+ return { settings: settingsPath(), command }
308
+ }
309
+
310
+ export async function uninstallHooks() {
311
+ if (!existsSync(settingsPath())) return false
312
+ const settings = await readSettings()
313
+ const hooks = withoutOurs(settings.hooks)
314
+ const changed = JSON.stringify(hooks) !== JSON.stringify(settings.hooks ?? {})
315
+ if (!changed) return false
316
+ const next = { ...settings, hooks }
317
+ if (Object.keys(hooks).length === 0) delete next.hooks
318
+ await writeSettings(next)
319
+ return true
320
+ }
321
+
322
+ export async function hooksInstalled() {
323
+ try {
324
+ const hooks = (await readSettings()).hooks ?? {}
325
+ return Object.values(hooks).some(
326
+ (groups) =>
327
+ Array.isArray(groups) && groups.some((g) => g?.hooks?.some?.(isOurs)),
328
+ )
329
+ } catch {
330
+ return false
331
+ }
332
+ }
333
+
334
+ export const __test = { withoutOurs, isOurs, trackerEvent }
@@ -0,0 +1,101 @@
1
+ import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test } from 'vite-plus/test'
5
+ import {
6
+ __test,
7
+ hooksInstalled,
8
+ installHooks,
9
+ uninstallHooks,
10
+ } from './hooks.mjs'
11
+
12
+ const { trackerEvent } = __test
13
+
14
+ describe('which Claude Code events count', () => {
15
+ test.each([
16
+ [{ hook_event_name: 'SessionStart', source: 'startup' }, 'start'],
17
+ [{ hook_event_name: 'UserPromptSubmit', prompt: 'hi' }, 'prompt'],
18
+ [{ hook_event_name: 'Stop' }, 'reply'],
19
+ [{ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }, 'end'],
20
+ [{ hook_event_name: 'SessionEnd', reason: 'logout' }, 'end'],
21
+ // /clear and /resume end a session id but not the work
22
+ [{ hook_event_name: 'SessionEnd', reason: 'clear' }, null],
23
+ [{ hook_event_name: 'SessionEnd', reason: 'resume' }, null],
24
+ [{ hook_event_name: 'PreToolUse' }, null],
25
+ ])('%j', (event, expected) => {
26
+ expect(trackerEvent(event)).toBe(expected)
27
+ })
28
+ })
29
+
30
+ describe('installing into ~/.claude/settings.json', () => {
31
+ let dir = ''
32
+ const saved = process.env.CLAUDE_CONFIG_DIR
33
+ const settings = async () =>
34
+ JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8'))
35
+
36
+ beforeEach(async () => {
37
+ dir = await mkdtemp(join(tmpdir(), 'bv-claude-'))
38
+ process.env.CLAUDE_CONFIG_DIR = dir
39
+ })
40
+ afterEach(() => {
41
+ if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR
42
+ else process.env.CLAUDE_CONFIG_DIR = saved
43
+ })
44
+
45
+ const theirs = {
46
+ theme: 'dark',
47
+ hooks: {
48
+ Stop: [{ hooks: [{ type: 'command', command: 'notify-send done' }] }],
49
+ PreToolUse: [
50
+ {
51
+ matcher: 'Bash',
52
+ hooks: [{ type: 'command', command: './guard.sh' }],
53
+ },
54
+ ],
55
+ },
56
+ }
57
+
58
+ test('adds our four hooks and keeps everything else', async () => {
59
+ await writeFile(join(dir, 'settings.json'), JSON.stringify(theirs))
60
+ await installHooks()
61
+ const s = await settings()
62
+ expect(s.theme).toBe('dark')
63
+ expect(s.hooks.PreToolUse).toEqual(theirs.hooks.PreToolUse)
64
+ expect(s.hooks.Stop[0]).toEqual(theirs.hooks.Stop[0])
65
+ for (const event of [
66
+ 'SessionStart',
67
+ 'UserPromptSubmit',
68
+ 'Stop',
69
+ 'SessionEnd',
70
+ ])
71
+ expect(JSON.stringify(s.hooks[event])).toMatch(/bestviber.*hook/)
72
+ expect(s.hooks.SessionEnd.at(-1).matcher).toBe(
73
+ 'prompt_input_exit|logout|other',
74
+ )
75
+ expect(await hooksInstalled()).toBe(true)
76
+ })
77
+
78
+ test('installing twice does not duplicate', async () => {
79
+ await installHooks()
80
+ await installHooks()
81
+ const s = await settings()
82
+ expect(s.hooks.UserPromptSubmit).toHaveLength(1)
83
+ })
84
+
85
+ test('uninstall removes only ours', async () => {
86
+ await writeFile(join(dir, 'settings.json'), JSON.stringify(theirs))
87
+ await installHooks()
88
+ expect(await uninstallHooks()).toBe(true)
89
+ const s = await settings()
90
+ expect(s).toEqual(theirs)
91
+ expect(await hooksInstalled()).toBe(false)
92
+ })
93
+
94
+ test('a settings file that is not valid JSON is left alone', async () => {
95
+ await writeFile(join(dir, 'settings.json'), '{ not json')
96
+ await expect(installHooks()).rejects.toThrow(/Left it unchanged/)
97
+ expect(await readFile(join(dir, 'settings.json'), 'utf8')).toBe(
98
+ '{ not json',
99
+ )
100
+ })
101
+ })
@@ -0,0 +1,113 @@
1
+ // Automatic tracking for Claude Code: what to do when a hook fires.
2
+ //
3
+ // Pure: given the saved state, the session the server says is open, the
4
+ // event and the time, return the API calls to make and the next state. The
5
+ // hook runner (hooks.mjs) does the I/O; the tests drive this directly.
6
+ //
7
+ // Rules (agreed with the product owner):
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.
12
+ // - Claude finishing a reply counts as activity; no API call is needed.
13
+ // - Quiet under GRACE (reading a reply) still counts as focus.
14
+ // - Quiet from GRACE up to FINISH is recorded as a pause in the same session.
15
+ // - Quiet of FINISH or more ends the session when the quiet began; the next
16
+ // prompt starts a new one.
17
+ // - Every call names its session by id, so sessions started by hand or by
18
+ // other windows are never touched.
19
+
20
+ export const GRACE_MS = 2 * 60_000
21
+ export const FINISH_MS = 10 * 60_000
22
+
23
+ /**
24
+ * @typedef {{ sessionId: string | null, project: string | null, lastActivity: string | null }} TrackerState
25
+ * @typedef {{ id: string, status: string, project?: string | null } | null} OpenSession
26
+ * @typedef {'start' | 'prompt' | 'reply' | 'end'} TrackerEvent
27
+ * @typedef {{ name: string, input: Record<string, unknown> }} Call
28
+ */
29
+
30
+ /** @type {TrackerState} */
31
+ export const EMPTY_STATE = {
32
+ sessionId: null,
33
+ project: null,
34
+ lastActivity: null,
35
+ }
36
+
37
+ /**
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.
41
+ * @returns {{ calls: Array<Call>, state: TrackerState }}
42
+ */
43
+ export function decide({ state, open, event, now, project, agentSession }) {
44
+ const nowIso = now.toISOString()
45
+ const last = state.lastActivity ? new Date(state.lastActivity) : null
46
+ const gap = last ? now.getTime() - last.getTime() : 0
47
+ const id = state.sessionId
48
+ const ours = open !== null && id !== null && open.id === id
49
+ /** When the work in the open session really stopped. */
50
+ const endedAt = last && gap >= GRACE_MS ? state.lastActivity : nowIso
51
+
52
+ if (event === 'reply')
53
+ return { calls: [], state: { ...state, lastActivity: nowIso } }
54
+
55
+ if (event === 'end')
56
+ return {
57
+ calls: ours
58
+ ? [{ name: 'finish_focus_session', input: { id, at: endedAt } }]
59
+ : [],
60
+ state: EMPTY_STATE,
61
+ }
62
+
63
+ // 'start' or 'prompt': make sure this window's session is running.
64
+ /** @type {Array<Call>} */
65
+ const calls = []
66
+ const start = {
67
+ name: 'start_focus_session',
68
+ input: { project, agentSession },
69
+ }
70
+
71
+ if (!ours) calls.push(start)
72
+ else if (open.project !== project || gap >= FINISH_MS) {
73
+ calls.push(
74
+ { name: 'finish_focus_session', input: { id, at: endedAt } },
75
+ start,
76
+ )
77
+ } else if (gap >= GRACE_MS) {
78
+ calls.push(
79
+ { name: 'pause_focus_session', input: { id, at: state.lastActivity } },
80
+ { name: 'resume_focus_session', input: { id } },
81
+ )
82
+ }
83
+
84
+ return {
85
+ calls,
86
+ // A new session's id comes back from start_focus_session; the runner
87
+ // fills it in.
88
+ state: {
89
+ sessionId: calls.includes(start) ? null : id,
90
+ project,
91
+ lastActivity: nowIso,
92
+ },
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The project for a working directory: "owner/repo" from the git remote,
98
+ * else the repository folder name, else the folder name.
99
+ *
100
+ * @param {{ remote: string | null, toplevel: string | null, cwd: string }} where
101
+ */
102
+ export function projectName({ remote, toplevel, cwd }) {
103
+ if (remote) {
104
+ // git@github.com:owner/repo.git, https://host/owner/repo(.git), ssh://…
105
+ const m = remote
106
+ .trim()
107
+ .replace(/\.git$/, '')
108
+ .match(/[:/]([^/:]+\/[^/:]+)$/)
109
+ if (m?.[1]) return m[1]
110
+ }
111
+ const dir = (toplevel || cwd).replace(/\/+$/, '')
112
+ return dir.slice(dir.lastIndexOf('/') + 1) || dir
113
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from 'vite-plus/test'
2
+ import { EMPTY_STATE, decide, projectName } from './tracker.mjs'
3
+
4
+ const at = (hm) => new Date(`2026-11-02T${hm}:00Z`)
5
+ const iso = (hm) => at(hm).toISOString()
6
+ const P = 'amar/bestviber'
7
+ const mine = (overrides = {}) => ({
8
+ id: 's1',
9
+ status: 'active',
10
+ project: P,
11
+ ...overrides,
12
+ })
13
+ const tracking = (lastHm, overrides = {}) => ({
14
+ sessionId: 's1',
15
+ project: P,
16
+ lastActivity: iso(lastHm),
17
+ ...overrides,
18
+ })
19
+
20
+ describe('automatic tracking', () => {
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
+ expect(r.state.lastActivity).toBe(iso('09:00'))
33
+ })
34
+
35
+ test('a quick next prompt changes nothing', () => {
36
+ const r = go(tracking('09:00'), mine(), 'prompt', '09:01')
37
+ expect(r.calls).toEqual([])
38
+ expect(r.state.sessionId).toBe('s1')
39
+ })
40
+
41
+ test('quiet between 2 and 10 minutes becomes a pause in the same session', () => {
42
+ const r = go(tracking('09:00'), mine(), 'prompt', '09:06')
43
+ expect(r.calls).toEqual([
44
+ { name: 'pause_focus_session', input: { id: 's1', at: iso('09:00') } },
45
+ { name: 'resume_focus_session', input: { id: 's1' } },
46
+ ])
47
+ expect(r.state.sessionId).toBe('s1')
48
+ })
49
+
50
+ test('quiet of 10 minutes or more ends the session when the quiet began', () => {
51
+ const r = go(tracking('09:00'), mine(), 'prompt', '09:30')
52
+ expect(r.calls).toEqual([
53
+ { name: 'finish_focus_session', input: { id: 's1', at: iso('09:00') } },
54
+ start(),
55
+ ])
56
+ expect(r.state.sessionId).toBeNull()
57
+ })
58
+
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')
61
+ expect(r.calls).toEqual([
62
+ { name: 'finish_focus_session', input: { id: 's1', at: iso('09:01') } },
63
+ start('amar/website'),
64
+ ])
65
+ })
66
+
67
+ test('a finished reply is activity, with no API call', () => {
68
+ const r = go(tracking('09:00'), mine(), 'reply', '09:04')
69
+ expect(r.calls).toEqual([])
70
+ expect(r.state.lastActivity).toBe(iso('09:04'))
71
+ })
72
+
73
+ test('Claude Code exiting finishes the session, at the last activity if it was quiet', () => {
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
+ ])
80
+ })
81
+
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')
88
+ })
89
+
90
+ test('if our session was closed elsewhere, the next prompt starts a new one', () => {
91
+ const r = go(tracking('09:00'), null, 'prompt', '09:01')
92
+ expect(r.calls).toEqual([start()])
93
+ })
94
+ })
95
+
96
+ describe('project names', () => {
97
+ test.each([
98
+ ['git@github.com:amaralazizy/bestviber.git', 'amaralazizy/bestviber'],
99
+ ['https://github.com/amaralazizy/bestviber.git', 'amaralazizy/bestviber'],
100
+ ['https://gitlab.com/team/app', 'team/app'],
101
+ ['ssh://git@host:2222/team/app.git', 'team/app'],
102
+ ])('%s', (remote, expected) => {
103
+ expect(projectName({ remote, toplevel: '/x/y', cwd: '/x/y/z' })).toBe(
104
+ expected,
105
+ )
106
+ })
107
+
108
+ test('falls back to the repository folder, then the working folder', () => {
109
+ expect(
110
+ projectName({
111
+ remote: null,
112
+ toplevel: '/home/a/notes',
113
+ cwd: '/home/a/notes/src',
114
+ }),
115
+ ).toBe('notes')
116
+ expect(
117
+ projectName({ remote: null, toplevel: null, cwd: '/home/a/scratch/' }),
118
+ ).toBe('scratch')
119
+ })
120
+ })