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 +24 -6
- package/bin/bestviber.mjs +3 -3
- package/package.json +1 -1
- package/src/commands.mjs +149 -29
- package/src/format.mjs +10 -0
- package/src/hooks.mjs +334 -0
- package/src/hooks.test.mjs +101 -0
- package/src/tracker.mjs +113 -0
- package/src/tracker.test.mjs +120 -0
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
|
|
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
|
|
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
|
-
##
|
|
34
|
+
## Other agents
|
|
19
35
|
|
|
20
|
-
-
|
|
21
|
-
|
|
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)
|
|
14
|
-
([
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
+
})
|
package/src/tracker.mjs
ADDED
|
@@ -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
|
+
})
|