bestviber 0.0.0-stage → 0.2.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/src/format.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Human-readable output. `--json` bypasses all of this.
2
+
3
+ /** @param {number} ms */
4
+ export function duration(ms) {
5
+ const totalMin = Math.round(ms / 60000)
6
+ if (ms > 0 && totalMin === 0) return `${Math.round(ms / 1000)}s`
7
+ const h = Math.floor(totalMin / 60)
8
+ const m = totalMin % 60
9
+ if (h === 0) return `${m}m`
10
+ return m ? `${h}h ${m}m` : `${h}h`
11
+ }
12
+
13
+ /** Elapsed focus on an open session, pauses removed. */
14
+ export function elapsed(/** @type {any} */ s, now = Date.now()) {
15
+ const start = Date.parse(s.startedAt)
16
+ const openPause = s.pausedAt ? now - Date.parse(s.pausedAt) : 0
17
+ return Math.max(0, now - start - s.pausedMs - openPause)
18
+ }
19
+
20
+ /** @param {string} iso */
21
+ export function clock(iso) {
22
+ return new Date(iso).toLocaleTimeString([], {
23
+ hour: '2-digit',
24
+ minute: '2-digit',
25
+ })
26
+ }
27
+
28
+ /** @param {number} value @param {number} max @param {number} width */
29
+ function bar(value, max, width = 24) {
30
+ if (max <= 0) return ''
31
+ const n = Math.round((value / max) * width)
32
+ return '█'.repeat(n) + '·'.repeat(width - n)
33
+ }
34
+
35
+ /** @param {any} stats */
36
+ export function stats(stats) {
37
+ const { range } = stats
38
+ const lines = []
39
+ const title =
40
+ range.from === range.to ? range.from : `${range.from} → ${range.to}`
41
+ lines.push(`${title} (${range.tz})`)
42
+ lines.push('')
43
+ lines.push(` focused ${duration(stats.totalMs)}`)
44
+ lines.push(` sessions ${stats.sessionCount}`)
45
+ if (stats.sessionCount) {
46
+ lines.push(` average ${duration(stats.averageSessionMs)}`)
47
+ lines.push(` longest ${duration(stats.longestSession.durationMs)}`)
48
+ }
49
+ const change = stats.changeVsPrevious
50
+ if (change.percent !== null)
51
+ lines.push(
52
+ ` vs previous ${change.percent >= 0 ? '+' : ''}${change.percent}% (${duration(stats.previous.totalMs)} before)`,
53
+ )
54
+ if (stats.byDay.length > 1) {
55
+ lines.push(` active days ${stats.activeDays}/${stats.byDay.length}`)
56
+ if (stats.bestDay)
57
+ lines.push(
58
+ ` best day ${stats.bestDay.date} (${duration(stats.bestDay.ms)})`,
59
+ )
60
+ }
61
+
62
+ if (stats.byDay.length > 1 && stats.byDay.length <= 31) {
63
+ const max = Math.max(...stats.byDay.map((/** @type {any} */ d) => d.ms))
64
+ lines.push('', ' by day')
65
+ for (const d of stats.byDay)
66
+ lines.push(
67
+ ` ${d.date.slice(5)} ${bar(d.ms, max)} ${d.ms ? duration(d.ms) : ''}`,
68
+ )
69
+ }
70
+
71
+ const hours = stats.byHour.filter((/** @type {any} */ h) => h.ms > 0)
72
+ if (hours.length > 1) {
73
+ const max = Math.max(...hours.map((/** @type {any} */ h) => h.ms))
74
+ lines.push('', ' by hour')
75
+ for (const h of hours)
76
+ lines.push(
77
+ ` ${String(h.hour).padStart(2, '0')}:00 ${bar(h.ms, max)} ${duration(h.ms)}`,
78
+ )
79
+ }
80
+
81
+ if (stats.byTag.length > 1 || stats.byTag[0]?.tag !== 'untagged') {
82
+ lines.push('', ' by tag')
83
+ for (const t of stats.byTag)
84
+ lines.push(` ${t.tag.padEnd(16)} ${duration(t.ms)}`)
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
+ }
96
+ return lines.join('\n')
97
+ }
package/src/hooks.mjs ADDED
@@ -0,0 +1,306 @@
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
+ await withLock(async () => {
91
+ const state = await readState()
92
+ 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 })
100
+ const next = { ...result.state }
101
+ for (const c of result.calls) {
102
+ const res = await call(c.name, c.input)
103
+ if (c.name === 'start_focus_session') next.sessionId = res?.id ?? null
104
+ }
105
+ await writeState(next)
106
+ })
107
+ } catch (err) {
108
+ await log(String(/** @type {Error} */ (err)?.stack ?? err))
109
+ }
110
+ }
111
+
112
+ function detectProject(/** @type {string} */ cwd) {
113
+ const git = (/** @type {string[]} */ args) => {
114
+ try {
115
+ return (
116
+ execFileSync('git', ['-C', cwd, ...args], {
117
+ encoding: 'utf8',
118
+ stdio: ['ignore', 'pipe', 'ignore'],
119
+ timeout: 1500,
120
+ }).trim() || null
121
+ )
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+ return projectName({
127
+ remote: git(['remote', 'get-url', 'origin']),
128
+ toplevel: git(['rev-parse', '--show-toplevel']),
129
+ cwd,
130
+ })
131
+ }
132
+
133
+ async function readState() {
134
+ try {
135
+ return {
136
+ ...EMPTY_STATE,
137
+ ...JSON.parse(await readFile(statePath(), 'utf8')),
138
+ }
139
+ } catch {
140
+ return EMPTY_STATE
141
+ }
142
+ }
143
+
144
+ async function writeState(/** @type {object} */ state) {
145
+ await mkdir(stateDir(), { recursive: true, mode: 0o700 })
146
+ await writeFile(statePath(), JSON.stringify(state) + '\n', { mode: 0o600 })
147
+ }
148
+
149
+ export async function clearTrackingState() {
150
+ await rm(statePath(), { force: true })
151
+ }
152
+
153
+ /** One worker at a time: a prompt and a reply can land together. */
154
+ async function withLock(/** @type {() => Promise<void>} */ fn) {
155
+ await mkdir(stateDir(), { recursive: true, mode: 0o700 })
156
+ const deadline = Date.now() + 8000
157
+ for (;;) {
158
+ try {
159
+ await mkdir(lockPath())
160
+ break
161
+ } catch {
162
+ // A lock older than 30 s belongs to a worker that died.
163
+ const age = await stat(lockPath())
164
+ .then((s) => Date.now() - s.mtimeMs)
165
+ .catch(() => 0)
166
+ if (age > 30_000) await rm(lockPath(), { recursive: true, force: true })
167
+ else if (Date.now() > deadline) return
168
+ else await new Promise((r) => setTimeout(r, 150))
169
+ }
170
+ }
171
+ try {
172
+ await fn()
173
+ } finally {
174
+ await rm(lockPath(), { recursive: true, force: true })
175
+ }
176
+ }
177
+
178
+ async function log(/** @type {string} */ line) {
179
+ try {
180
+ const size = await stat(logPath())
181
+ .then((s) => s.size)
182
+ .catch(() => 0)
183
+ if (size > 100_000) await rm(logPath(), { force: true })
184
+ await appendFile(logPath(), `${new Date().toISOString()} ${line}\n`)
185
+ } catch {
186
+ // Nowhere left to report to.
187
+ }
188
+ }
189
+
190
+ // ───── installing into Claude Code ─────
191
+
192
+ const claudeDir = () =>
193
+ process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
194
+ const settingsPath = () => join(claudeDir(), 'settings.json')
195
+
196
+ /** Whether Claude Code is set up on this machine. */
197
+ export const hasClaudeCode = () => existsSync(claudeDir())
198
+
199
+ /** The command Claude Code runs, with an absolute path (hooks may not share the shell's PATH). */
200
+ function hookCommand() {
201
+ const q = (/** @type {string} */ s) => `"${s.replace(/(["\\$`])/g, '\\$1')}"`
202
+ let onPath = null
203
+ try {
204
+ onPath = execFileSync('sh', ['-c', 'command -v bestviber'], {
205
+ encoding: 'utf8',
206
+ stdio: ['ignore', 'pipe', 'ignore'],
207
+ }).trim()
208
+ } catch {
209
+ // Not on PATH (for example run through pnpm dlx).
210
+ }
211
+ return onPath ? `${q(onPath)} hook` : `${q(process.execPath)} ${q(BIN)} hook`
212
+ }
213
+
214
+ const isOurs = (/** @type {any} */ h) =>
215
+ typeof h?.command === 'string' &&
216
+ /bestviber(\.mjs)?"?\s+hook$/.test(h.command)
217
+
218
+ /** Remove our hook entries from every event, keeping everything else. */
219
+ function withoutOurs(/** @type {Record<string, any>} */ hooks) {
220
+ /** @type {Record<string, any>} */
221
+ const cleaned = {}
222
+ for (const [event, groups] of Object.entries(hooks ?? {})) {
223
+ if (!Array.isArray(groups)) {
224
+ cleaned[event] = groups
225
+ continue
226
+ }
227
+ const kept = groups
228
+ .map((g) => ({
229
+ ...g,
230
+ hooks: Array.isArray(g?.hooks)
231
+ ? g.hooks.filter((h) => !isOurs(h))
232
+ : g?.hooks,
233
+ }))
234
+ .filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
235
+ if (kept.length) cleaned[event] = kept
236
+ }
237
+ return cleaned
238
+ }
239
+
240
+ async function readSettings() {
241
+ try {
242
+ return JSON.parse(await readFile(settingsPath(), 'utf8'))
243
+ } catch (err) {
244
+ if (/** @type {any} */ (err).code === 'ENOENT') return {}
245
+ throw new Error(
246
+ `Could not read ${settingsPath()} (${/** @type {Error} */ (err).message}). Left it unchanged.`,
247
+ )
248
+ }
249
+ }
250
+
251
+ async function writeSettings(/** @type {object} */ settings) {
252
+ const path = settingsPath()
253
+ await mkdir(dirname(path), { recursive: true })
254
+ if (existsSync(path) && !existsSync(`${path}.bestviber-backup`))
255
+ await copyFile(path, `${path}.bestviber-backup`)
256
+ const tmp = `${path}.bestviber-tmp`
257
+ await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n')
258
+ await rename(tmp, path)
259
+ }
260
+
261
+ export async function installHooks() {
262
+ const settings = await readSettings()
263
+ const command = hookCommand()
264
+ const entry = { type: 'command', command, timeout: 10 }
265
+ const hooks = withoutOurs(settings.hooks)
266
+ /** @type {Array<[string, string | undefined]>} */
267
+ const events = [
268
+ ['SessionStart', undefined],
269
+ ['UserPromptSubmit', undefined],
270
+ ['Stop', undefined],
271
+ ['SessionEnd', 'prompt_input_exit|logout|other'],
272
+ ]
273
+ for (const [event, matcher] of events)
274
+ hooks[event] = [
275
+ ...(hooks[event] ?? []),
276
+ matcher ? { matcher, hooks: [entry] } : { hooks: [entry] },
277
+ ]
278
+ await writeSettings({ ...settings, hooks })
279
+ return { settings: settingsPath(), command }
280
+ }
281
+
282
+ export async function uninstallHooks() {
283
+ if (!existsSync(settingsPath())) return false
284
+ const settings = await readSettings()
285
+ const hooks = withoutOurs(settings.hooks)
286
+ const changed = JSON.stringify(hooks) !== JSON.stringify(settings.hooks ?? {})
287
+ if (!changed) return false
288
+ const next = { ...settings, hooks }
289
+ if (Object.keys(hooks).length === 0) delete next.hooks
290
+ await writeSettings(next)
291
+ return true
292
+ }
293
+
294
+ export async function hooksInstalled() {
295
+ try {
296
+ const hooks = (await readSettings()).hooks ?? {}
297
+ return Object.values(hooks).some(
298
+ (groups) =>
299
+ Array.isArray(groups) && groups.some((g) => g?.hooks?.some?.(isOurs)),
300
+ )
301
+ } catch {
302
+ return false
303
+ }
304
+ }
305
+
306
+ 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,108 @@
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
+ // - 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.
10
+ // - Claude finishing a reply counts as activity; no API call is needed.
11
+ // - Quiet under GRACE (reading a reply) still counts as focus.
12
+ // - Quiet from GRACE up to FINISH is recorded as a pause in the same session.
13
+ // - Quiet of FINISH or more ends the session when the quiet began; the next
14
+ // prompt starts a new one.
15
+ // - A session someone started by hand (CLI, app, agent) is left alone.
16
+
17
+ export const GRACE_MS = 2 * 60_000
18
+ export const FINISH_MS = 10 * 60_000
19
+
20
+ /**
21
+ * @typedef {{ sessionId: string | null, project: string | null, lastActivity: string | null }} TrackerState
22
+ * @typedef {{ id: string, status: string, project?: string | null } | null} OpenSession
23
+ * @typedef {'start' | 'prompt' | 'reply' | 'end'} TrackerEvent
24
+ * @typedef {{ name: string, input: Record<string, unknown> }} Call
25
+ */
26
+
27
+ /** @type {TrackerState} */
28
+ export const EMPTY_STATE = {
29
+ sessionId: null,
30
+ project: null,
31
+ lastActivity: null,
32
+ }
33
+
34
+ /**
35
+ * @param {{ state: TrackerState, open: OpenSession, event: TrackerEvent, now: Date, project: string }} input
36
+ * @returns {{ calls: Array<Call>, state: TrackerState }}
37
+ */
38
+ export function decide({ state, open, event, now, project }) {
39
+ const nowIso = now.toISOString()
40
+ const last = state.lastActivity ? new Date(state.lastActivity) : null
41
+ const gap = last ? now.getTime() - last.getTime() : 0
42
+ const ours = open !== null && open.id === state.sessionId
43
+ /** When the work in the open session really stopped. */
44
+ const endedAt = last && gap >= GRACE_MS ? state.lastActivity : nowIso
45
+
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
+ if (event === 'reply')
54
+ return { calls: [], state: { ...state, lastActivity: nowIso } }
55
+
56
+ if (event === 'end') {
57
+ if (!ours) return { calls: [], state: EMPTY_STATE }
58
+ return {
59
+ calls: [{ name: 'finish_focus_session', input: { at: endedAt } }],
60
+ state: EMPTY_STATE,
61
+ }
62
+ }
63
+
64
+ // 'start' or 'prompt': make sure this project's session is running.
65
+ /** @type {Array<Call>} */
66
+ const calls = []
67
+ const start = { name: 'start_focus_session', input: { project } }
68
+
69
+ if (!ours) calls.push(start)
70
+ else if (open.project !== project || gap >= FINISH_MS) {
71
+ calls.push({ name: 'finish_focus_session', input: { at: endedAt } }, start)
72
+ } else if (gap >= GRACE_MS) {
73
+ calls.push(
74
+ { name: 'pause_focus_session', input: { at: state.lastActivity } },
75
+ { name: 'resume_focus_session', input: {} },
76
+ )
77
+ }
78
+
79
+ return {
80
+ calls,
81
+ // A new session's id comes back from start_focus_session; the runner
82
+ // fills it in.
83
+ state: {
84
+ sessionId: calls.includes(start) ? null : state.sessionId,
85
+ project,
86
+ lastActivity: nowIso,
87
+ },
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The project for a working directory: "owner/repo" from the git remote,
93
+ * else the repository folder name, else the folder name.
94
+ *
95
+ * @param {{ remote: string | null, toplevel: string | null, cwd: string }} where
96
+ */
97
+ export function projectName({ remote, toplevel, cwd }) {
98
+ if (remote) {
99
+ // git@github.com:owner/repo.git, https://host/owner/repo(.git), ssh://…
100
+ const m = remote
101
+ .trim()
102
+ .replace(/\.git$/, '')
103
+ .match(/[:/]([^/:]+\/[^/:]+)$/)
104
+ if (m?.[1]) return m[1]
105
+ }
106
+ const dir = (toplevel || cwd).replace(/\/+$/, '')
107
+ return dir.slice(dir.lastIndexOf('/') + 1) || dir
108
+ }