@ai-adoption-score/ai-coding-usage 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AI Adoption Score
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ # @ai-adoption-score/ai-coding-usage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The add-on behind [AI Coding Usage](https://ai-adoption-score.app/product/ai-coding-usage), a survey in AI Adoption
4
+ Score. It counts how a person uses Claude Code and Codex on their Mac and, once connected, shares the numbers with their
5
+ company's survey. Prompts, the AI's answers, code, file names, command text and session titles never leave the Mac.
6
+
7
+ You don't install it yourself: your survey page shows one line with a code,
8
+ `npx @ai-adoption-score/ai-coding-usage@1 connect <code>`, to paste into Claude Code, Codex or Terminal.
9
+
10
+ Connected once, it shares by itself: a hook in Claude Code and Codex syncs after a session's replies, at most every 5
11
+ minutes and always when a session ends, and an automatic ping-back, once an hour and without any numbers, tells the
12
+ report "no AI use today" from "no signal".
13
+
14
+ ## Commands
15
+
16
+ - `connect <code> [--server <url>]`: links this Mac to the survey with the code from the survey page and puts the
17
+ hooks and the ping-back in place. Then it sends the names of the repositories used in the last 30 days, and nothing
18
+ else, so the person can choose on their survey page which to share; a background wait shares the last 30 days of
19
+ those once they press Share. The server defaults to `https://app.ai-adoption-score.app`;
20
+ `AI_ADOPTION_SCORE_SERVER` or `--server http://localhost:3000` point it at a local app. Run again on a Mac that is
21
+ connected, it keeps that Mac's link on the same server (the survey page still shows one Mac) and lets the person
22
+ choose again; a link on another server is disconnected, and its token never goes to the new one.
23
+ - `sync`: shares now instead of after the next session. It counts the window again and sends the rows that changed
24
+ since the server last accepted them; the server replaces what it had, so nothing counts twice.
25
+ - `status`: which survey this Mac shares with, what the person chose, the hooks and when they last ran, and when it
26
+ last shared.
27
+ - `disconnect`: stops sharing from this Mac and removes everything it put in place.
28
+ - `preview [--days 30] [--company github.com/acme] [--json]`: what would be shared, without sending.
29
+
30
+ The hooks run `hook <tool>` and the ping-back runs `check-in`, its name inside the add-on; people don't.
31
+
32
+ ## What it puts in place
33
+
34
+ - `~/.ai-adoption-score/`, readable only by the Mac's user: `config.json` with the device token, `state.json` with a
35
+ fingerprint of every row the server accepted and when each hook last ran, a short `log.txt`, a copy of the add-on in
36
+ `app/` (a hook can't depend on npx's temporary folder), and `run.sh`, which finds Node for the hooks, because desktop
37
+ apps don't load the shell's PATH.
38
+ - A hook for `SessionStart`, `Stop` and `SessionEnd` in `~/.claude/settings.json` and `~/.codex/hooks.json`, added
39
+ after the hooks already there. The command never changes between versions, because Codex trusts a hook by its exact
40
+ text, its name and its place. Codex runs it only once the person trusts it: the Codex app lists it under Settings →
41
+ Hooks as "AI Adoption Score", a "New hook" with a Trust button, and Codex in Terminal asks when it starts.
42
+ - `~/Library/LaunchAgents/app.ai-adoption-score.check-in.plist`, the ping-back, once an hour at low priority.
43
+
44
+ The settings files are written in their own style (indent, last newline, `\u` escapes), through a link when the file
45
+ is one, so taking the hook out leaves them byte for byte as they were.
46
+
47
+ A hook prints nothing (a `SessionStart` hook's output would go into the conversation, and Codex refuses text from a
48
+ `Stop` hook), returns in about 40 ms and starts the sync in its own low-priority process. One sync runs at a time. A
49
+ sync that can't reach the server keeps the rows changed, so the next one sends them: the Mac is the queue.
50
+
51
+ **Each tool pauses on its own.** After the first sync, a tool's rows go out only while its hook is in place and has
52
+ run since connecting. Declining the Codex hook, or deleting either hook, pauses that tool; when the hook is back, the
53
+ waiting days go out, within the 30 days the records last.
54
+
55
+ **It removes itself** when the survey page disconnects the Mac or the survey closes: the next sync or ping-back hears
56
+ it and takes away the hooks, the ping-back and the folder. If someone deletes the folder by hand, the leftover hooks do
57
+ nothing, and `disconnect` still takes them away.
58
+
59
+ For testing, `AI_ADOPTION_SCORE_HOME`, `AI_ADOPTION_SCORE_CLAUDE_SETTINGS`, `AI_ADOPTION_SCORE_CODEX_HOOKS` and
60
+ `AI_ADOPTION_SCORE_LAUNCH_AGENTS` move each of these elsewhere, and `AI_ADOPTION_SCORE_SKIP_LAUNCHCTL=1` leaves launchd
61
+ alone. `AI_ADOPTION_SCORE_NODE` names a Node for `run.sh` to try.
62
+
63
+ ## Try it
64
+
65
+ Count this Mac's last 30 days and see what would be shared, without connecting and without sending anything:
66
+
67
+ ```bash
68
+ npx @ai-adoption-score/ai-coding-usage@1 preview
69
+ ```
70
+
71
+ Add `--json` to see the exact rows, or `--days 7` for a shorter window. It needs Node.js 18.17 or newer and git, and
72
+ runs on macOS only for now.
73
+
74
+ ## What it reads
75
+
76
+ - **Claude Code:** `~/.claude/projects/**/*.jsonl`, or `$CLAUDE_CONFIG_DIR/projects`. For each line: its kind, time,
77
+ session, folder and branch, the model and token counts of the AI's replies, and the names of the tools it used.
78
+ For the AI's edits, the diff Claude Code stores with each result. Lines that hold prompt text or titles
79
+ (`last-prompt`, `custom-title`, `ai-title`) are skipped.
80
+ - **Codex:** `~/.codex/sessions/**/rollout-*.jsonl`, or `$CODEX_HOME/sessions`. For each line: its kind, time, thread,
81
+ folder, repository and branch, the model, per-reply token records, and tool and connector names.
82
+ `session_index.jsonl`, which holds thread names, is never opened.
83
+ - **The Claude desktop app's connector names:** only `remoteMcpServersConfig` in its session files, so claude.ai
84
+ connectors show as "Notion" rather than an id. Everything else in those files is skipped.
85
+ - **git, in each session's folder:** the remote's address, which branches exist on a remote, and the person's own
86
+ commits by time. No file is opened and nothing is fetched.
87
+
88
+ Text is looked at only to tell a prompt from a tool result and to count changed lines. An edited file's path is
89
+ used only to check that the edit is inside the session's project. Neither is ever kept.
90
+
91
+ ## Which repositories
92
+
93
+ Nothing from the sessions leaves the Mac before the person chooses. The page lists the repositories the add-on found,
94
+ by owner, all ticked to start with; the names of the ones they untick are deleted when they press Share, and sessions
95
+ already shared from them are deleted too. Before every sync the add-on asks what they chose (`GET
96
+ /api/usage/repositories`) and sends only those sessions (`src/choice.mjs`); the server checks the same rule again.
97
+
98
+ A repository that shows up later is shared when its owner (`github.com/acme`) was ticked in full, and so is a new
99
+ folder without a remote when every such folder was ticked. Anything else stays on the Mac until the person runs the line
100
+ again and ticks it. Work outside any repository is one more box.
101
+
102
+ ## What a row holds
103
+
104
+ One row per session, day, repository and branch: the tool, a hash of the session id, the day and the first and last
105
+ moment, the repository and branch and whether the branch is pushed, active minutes, prompts, stops, rejected actions,
106
+ commands, file edits, lines added and removed, connector calls by name, skills, helper agents, commits and pushed
107
+ commits, and per model its replies and tokens. `npx @ai-adoption-score/ai-coding-usage@1 preview --json` prints the exact rows this Mac
108
+ would send.
109
+
110
+ Never shared: prompts, AI answers, code, file names and paths, command text, session titles.
111
+
112
+ ## How it counts
113
+
114
+ - Each Claude Code reply is written as several lines that repeat its usage, so it counts once, by message id and
115
+ request id. A resumed session copies earlier lines into a new file, so each line counts once, by its id.
116
+ - A helper agent's file carries its parent's session id: its tokens and tool calls count in the parent session.
117
+ - A Codex thread that Codex starts itself, such as its automatic reviewer, counts in the session that started it,
118
+ with no session or prompts of its own.
119
+ - Codex's input count includes the cached part; rows keep them apart, as Claude Code does.
120
+ - Connectors are tools someone connected: GitHub, Linear, Notion. The apps' own tools (the desktop app's browser,
121
+ terminal and session tools, Codex's code runners) aren't counted.
122
+ - A removed folder, such as a deleted worktree, counts under the repository around it, when that still exists.
123
+ - An edit counts once Claude Code reports it done, and only inside the session's project: its repository, or its
124
+ folder outside git. Its lines come from the diff Claude Code stores with the result, and a new file counts all its
125
+ lines as added. A failed or turned-down edit, or a scratch file elsewhere, doesn't count.
126
+ - Active minutes add up the gaps under five minutes. A commit counts for the session whose window holds it, from its
127
+ first moment to an hour after its last; each commit counts once.
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ import { runCli } from '../src/cli.mjs'
4
+
5
+ runCli(process.argv.slice(2)).then(
6
+ (code) => {
7
+ process.exitCode = code
8
+ },
9
+ (error) => {
10
+ process.stderr.write(`Something went wrong: ${error instanceof Error ? error.message : String(error)}\n`)
11
+ process.exitCode = 1
12
+ }
13
+ )
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@ai-adoption-score/ai-coding-usage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Shares how you use Claude Code and Codex with your company's AI Adoption Score survey. Never prompts, AI answers, code or file names.",
5
+ "homepage": "https://ai-adoption-score.app/product/ai-coding-usage",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ai-adoption-score/ai-coding-usage.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/ai-adoption-score/ai-coding-usage/issues",
12
+ "email": "support@ai-adoption-score.app"
13
+ },
14
+ "author": "AI Adoption Score <support@ai-adoption-score.app> (https://ai-adoption-score.app)",
15
+ "keywords": [
16
+ "ai-adoption-score",
17
+ "claude-code",
18
+ "codex",
19
+ "ai-coding",
20
+ "usage"
21
+ ],
22
+ "type": "module",
23
+ "bin": {
24
+ "ai-coding-usage": "bin/ai-coding-usage.mjs"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "src",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18.17"
33
+ },
34
+ "license": "MIT",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // @ts-check
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ /** Talking to the survey's server. Only JSON with numbers and names goes out; the device token goes in a header. */
5
+
6
+ const TIMEOUT_MS = 20_000
7
+
8
+ export const CLIENT_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
9
+
10
+ /**
11
+ * @param {string} server @param {string} path @param {unknown} body @param {string} [token]
12
+ * @returns {Promise<{ status: number, body: any, retryAfterSeconds: number | null }>}
13
+ */
14
+ export async function post(server, path, body, token) {
15
+ return request(server, path, 'POST', body, token)
16
+ }
17
+
18
+ /**
19
+ * @param {string} server @param {string} path @param {string} [token]
20
+ * @returns {Promise<{ status: number, body: any, retryAfterSeconds: number | null }>}
21
+ */
22
+ export async function get(server, path, token) {
23
+ return request(server, path, 'GET', undefined, token)
24
+ }
25
+
26
+ /** @param {string} server @param {string} path @param {'GET' | 'POST'} method @param {unknown} body @param {string} [token] */
27
+ async function request(server, path, method, body, token) {
28
+ const controller = new AbortController()
29
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
30
+ try {
31
+ const response = await fetch(new URL(path, server), {
32
+ method,
33
+ headers: {
34
+ ...(method === 'POST' ? { 'content-type': 'application/json' } : {}),
35
+ 'user-agent': `ai-coding-usage/${CLIENT_VERSION}`,
36
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
37
+ },
38
+ ...(method === 'POST' ? { body: JSON.stringify(body) } : {}),
39
+ signal: controller.signal,
40
+ })
41
+ let parsed = null
42
+ try {
43
+ parsed = await response.json()
44
+ } catch {
45
+ parsed = null
46
+ }
47
+ const retryAfter = Number(response.headers.get('retry-after'))
48
+ return { status: response.status, body: parsed, retryAfterSeconds: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null }
49
+ } finally {
50
+ clearTimeout(timer)
51
+ }
52
+ }
@@ -0,0 +1,96 @@
1
+ // @ts-check
2
+ import { existsSync, readdirSync, statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { CLIENT_VERSION, post } from './api.mjs'
5
+ import { isCurrentConfig, readConfig } from './config.mjs'
6
+ import { hookState, uninstallAll } from './install.mjs'
7
+ import { log } from './log.mjs'
8
+ import { paths } from './paths.mjs'
9
+ import { localDay } from './rows.mjs'
10
+ import { readState, updateState } from './state.mjs'
11
+
12
+ /**
13
+ * The hourly check-in (a launchd agent): tells the survey this Mac is there, and whether each tool's hook is still in
14
+ * place and running. It sends no usage numbers. A day with a check-in and no use is a real zero in the report; a day
15
+ * without one is a gap.
16
+ */
17
+
18
+ const NOT_APPROVED_GRACE_MS = 15 * 60_000
19
+
20
+ /** Whether any of a tool's records changed since a moment: only file dates are read, never their contents. */
21
+ function recordsChangedSince(dir, since) {
22
+ if (!existsSync(dir)) return false
23
+ const stack = [dir]
24
+ while (stack.length > 0) {
25
+ const current = /** @type {string} */ (stack.pop())
26
+ let entries
27
+ try {
28
+ entries = readdirSync(current, { withFileTypes: true })
29
+ } catch {
30
+ continue
31
+ }
32
+ for (const entry of entries) {
33
+ const path = join(current, entry.name)
34
+ if (entry.isDirectory()) stack.push(path)
35
+ else if (entry.name.endsWith('.jsonl')) {
36
+ try {
37
+ if (statSync(path).mtimeMs > since) return true
38
+ } catch {
39
+ // Gone between listing and reading.
40
+ }
41
+ }
42
+ }
43
+ }
44
+ return false
45
+ }
46
+
47
+ /**
48
+ * A tool's hook as the check-in reports it: `not-approved` when it is in place but hasn't run since connecting although
49
+ * the tool was used, as with a Codex hook nobody approved yet.
50
+ * @param {'claude-code' | 'codex'} tool
51
+ */
52
+ export function reportedHookState(tool, config, state, env = process.env) {
53
+ const present = hookState(tool, env)
54
+ if (present !== 'installed') return present
55
+ const ran = state.hookRuns?.[tool]
56
+ const connected = Date.parse(config.connectedAt)
57
+ if (ran && Date.parse(ran) >= connected) return 'installed'
58
+ // A session open while connecting keeps writing before its hook first runs: only use well after connecting counts.
59
+ const records = tool === 'claude-code' ? paths(env).claudeRecords : paths(env).codexRecords
60
+ return recordsChangedSince(records, connected + NOT_APPROVED_GRACE_MS) ? 'not-approved' : 'installed'
61
+ }
62
+
63
+ export async function runCheckIn(env = process.env) {
64
+ const config = readConfig(env)
65
+ if (!config) {
66
+ // Settings gone but the check-in left behind: take it away too.
67
+ uninstallAll(env)
68
+ return 0
69
+ }
70
+ const state = readState(env)
71
+ const checkIn = {
72
+ day: localDay(Date.now()),
73
+ claudeHook: reportedHookState('claude-code', config, state, env),
74
+ codexHook: reportedHookState('codex', config, state, env),
75
+ }
76
+ try {
77
+ const response = await post(config.server, '/api/usage/sync', { schemaVersion: 1, clientVersion: CLIENT_VERSION, rows: [], checkIn }, config.token)
78
+ const status = response.body?.status
79
+ if ((status === 'revoked' || status === 'closed') && !isCurrentConfig(config, env)) {
80
+ log(`check-in: ${status} for an earlier connection; this Mac connected again, so nothing is removed`, env)
81
+ return 0
82
+ }
83
+ if (status === 'revoked' || status === 'closed') {
84
+ log(`check-in: ${status}, removing the add-on`, env)
85
+ uninstallAll(env)
86
+ return 0
87
+ }
88
+ updateState((current) => {
89
+ current.lastCheckInAt = new Date().toISOString()
90
+ }, env)
91
+ log(`check-in: ${response.status} ${status ?? ''} claude=${checkIn.claudeHook} codex=${checkIn.codexHook}`, env)
92
+ } catch (error) {
93
+ log(`check-in failed: ${error instanceof Error ? error.message : String(error)}`, env)
94
+ }
95
+ return 0
96
+ }
package/src/choice.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Which repositories the person shares. They choose on their survey page when this Mac connects: the add-on sends the
5
+ * names of the repositories it found, nothing else, and every sync sends only the sessions of what they chose. A new
6
+ * repository is shared when its owner was ticked in full; otherwise it stays on this Mac until they run the line again.
7
+ * The server applies the same rule.
8
+ *
9
+ * @typedef {{ repositories: string[], owners: string[], outsideGit: boolean, localFolders: boolean } | { all: true }} RepositoryChoice
10
+ */
11
+
12
+ /** A git repository without a remote, named after its folder. */
13
+ export const LOCAL_PREFIX = 'local:'
14
+
15
+ /** The owner of `github.com/acme/app`: `github.com/acme`. None for a folder without a remote. @param {string} repository */
16
+ export function repositoryOwner(repository) {
17
+ if (!repository || repository.startsWith(LOCAL_PREFIX)) return null
18
+ const parts = repository.split('/')
19
+ return parts.length >= 3 ? `${parts[0]}/${parts[1]}`.toLowerCase() : null
20
+ }
21
+
22
+ /**
23
+ * Whether a session in `repository` (null outside any repository) is shared. Nothing is, before the person chooses.
24
+ * @param {RepositoryChoice | null} choice @param {string | null} repository
25
+ */
26
+ export function isShared(choice, repository) {
27
+ if (!choice) return false
28
+ if ('all' in choice) return true
29
+ if (!repository) return choice.outsideGit
30
+ if (choice.repositories.includes(repository)) return true
31
+ if (repository.startsWith(LOCAL_PREFIX)) return choice.localFolders
32
+ const owner = repositoryOwner(repository)
33
+ return owner !== null && choice.owners.some((ticked) => ticked.toLowerCase() === owner)
34
+ }
35
+
36
+ /** At most this many names go to the page: the most recently used, if there are more. */
37
+ const MAX_REPOSITORIES = 500
38
+
39
+ /**
40
+ * What the person chooses from: the names of the repositories the rows ran in, and whether any work ran outside a
41
+ * repository. Nothing about the sessions themselves.
42
+ * @param {Array<{ repository: string | null, lastEventAt: string }>} rows
43
+ */
44
+ export function repositoryList(rows) {
45
+ /** @type {Map<string, string>} */
46
+ const lastUse = new Map()
47
+ let outsideGit = false
48
+ for (const row of rows) {
49
+ if (!row.repository) {
50
+ outsideGit = true
51
+ continue
52
+ }
53
+ const seen = lastUse.get(row.repository)
54
+ if (!seen || row.lastEventAt > seen) lastUse.set(row.repository, row.lastEventAt)
55
+ }
56
+ const repositories = [...lastUse.entries()]
57
+ .sort((left, right) => right[1].localeCompare(left[1]))
58
+ .slice(0, MAX_REPOSITORIES)
59
+ .map(([name]) => name)
60
+ .sort((left, right) => left.localeCompare(right))
61
+ return { schemaVersion: /** @type {const} */ (1), repositories, outsideGit }
62
+ }
63
+
64
+ /** What this Mac shares, in a line for `status`. @param {RepositoryChoice | null} choice */
65
+ export function describeChoice(choice) {
66
+ if (!choice) return 'nothing yet: choose your repositories on your survey page'
67
+ if ('all' in choice) return 'every repository'
68
+ const parts = [`${choice.repositories.length} ${choice.repositories.length === 1 ? 'repository' : 'repositories'}`]
69
+ if (choice.owners.length > 0) parts.push(`new ones from ${choice.owners.join(', ')}`)
70
+ if (choice.outsideGit) parts.push('work outside a repository')
71
+ return parts.join(', ')
72
+ }
@@ -0,0 +1,213 @@
1
+ // @ts-check
2
+ import { createReadStream, readdirSync, statSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join, resolve, sep } from 'node:path'
5
+ import { createInterface } from 'node:readline'
6
+ import { claudeConnectorName } from './connectors.mjs'
7
+ import { addConnectorCall, addTokens, changedLines, countLines } from './rows.mjs'
8
+
9
+ /**
10
+ * Claude Code's records: one file per session under `~/.claude/projects`, and helper agents' files beside them.
11
+ * Each line is read for its kind, time, folder, branch, model and token counts, and for the names of the tools the
12
+ * AI used. Text is only looked at to tell a prompt from a tool result and to count changed lines; it is never kept.
13
+ * Lines holding prompt text or titles (`last-prompt`, `custom-title`, `ai-title`) are skipped without reading them.
14
+ *
15
+ * An edit counts once Claude Code reports it done, with the lines from the diff it stores beside the result. Only
16
+ * edits inside the session's project count: its repository, or its folder outside git. The file's path is used for
17
+ * that check alone and is never kept.
18
+ */
19
+
20
+ const SKIPPED_TYPES = new Set(['last-prompt', 'custom-title', 'ai-title', 'summary'])
21
+ const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
22
+ const INTERRUPTED = '[Request interrupted by user'
23
+ const REJECTED = 'want to proceed with this tool use'
24
+
25
+ /** Every `.jsonl` file under a folder, changed since `since`. */
26
+ function recentFiles(dir, since, found = []) {
27
+ let entries
28
+ try {
29
+ entries = readdirSync(dir, { withFileTypes: true })
30
+ } catch {
31
+ return found
32
+ }
33
+ for (const entry of entries) {
34
+ const path = join(dir, entry.name)
35
+ if (entry.isDirectory()) recentFiles(path, since, found)
36
+ else if (entry.name.endsWith('.jsonl')) {
37
+ try {
38
+ if (statSync(path).mtimeMs >= since) found.push(path)
39
+ } catch {
40
+ // Gone between listing and reading.
41
+ }
42
+ }
43
+ }
44
+ return found
45
+ }
46
+
47
+ function textOf(content) {
48
+ if (typeof content === 'string') return content
49
+ if (Array.isArray(content)) return content.map((block) => (block && typeof block.text === 'string' ? block.text : '')).join('\n')
50
+ return ''
51
+ }
52
+
53
+ /**
54
+ * @param {{ since: number, env: NodeJS.ProcessEnv, rows: ReturnType<import('./rows.mjs').createRowSet>,
55
+ * gitInfo: ReturnType<import('./git.mjs').createGitInfo>, desktopNames: Map<string, string>,
56
+ * health: { files: number, lines: number, unreadable: number } }} options
57
+ */
58
+ export async function readClaudeCode({ since, env, rows, gitInfo, desktopNames, health }) {
59
+ const root = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects')
60
+ // A resumed or forked session copies earlier lines into a new file: each line, reply and tool call counts once.
61
+ const seenLines = new Set()
62
+ const seenReplies = new Set()
63
+ const seenToolUses = new Set()
64
+ /** Edits waiting for their result. @type {Map<string, { row: import('./rows.mjs').RowDraft, project: string | null, input: Record<string, any>, name: string }>} */
65
+ const pendingEdits = new Map()
66
+
67
+ for (const file of recentFiles(root, since)) {
68
+ health.files += 1
69
+ const lines = createInterface({ input: createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity })
70
+ for await (const line of lines) {
71
+ if (!line) continue
72
+ health.lines += 1
73
+ let entry
74
+ try {
75
+ entry = JSON.parse(line)
76
+ } catch {
77
+ health.unreadable += 1
78
+ continue
79
+ }
80
+ if (!entry || typeof entry !== 'object' || SKIPPED_TYPES.has(entry.type)) continue
81
+ const time = Date.parse(entry.timestamp)
82
+ if (!Number.isFinite(time) || time < since || typeof entry.sessionId !== 'string') continue
83
+ if (typeof entry.uuid === 'string') {
84
+ if (seenLines.has(entry.uuid)) continue
85
+ seenLines.add(entry.uuid)
86
+ }
87
+ if (entry.type !== 'assistant' && entry.type !== 'user') continue
88
+
89
+ const { repository, top } = gitInfo.repositoryFor(typeof entry.cwd === 'string' ? entry.cwd : null)
90
+ const row = rows.rowFor({
91
+ tool: 'claude-code',
92
+ sessionId: entry.sessionId,
93
+ time,
94
+ repository,
95
+ // "HEAD" means no branch was checked out, during a rebase for example.
96
+ branch: typeof entry.gitBranch === 'string' && entry.gitBranch && entry.gitBranch !== 'HEAD' ? entry.gitBranch : null,
97
+ gitTop: top,
98
+ })
99
+ const message = entry.message ?? {}
100
+
101
+ if (entry.type === 'assistant') {
102
+ const usage = message.usage
103
+ const model = message.model
104
+ // One reply is written as several lines that repeat its usage: count it once.
105
+ const replyKey = `${message.id}|${entry.requestId}`
106
+ if (usage && typeof model === 'string' && model !== '<synthetic>' && !seenReplies.has(replyKey)) {
107
+ seenReplies.add(replyKey)
108
+ addTokens(row, model, {
109
+ input: usage.input_tokens,
110
+ output: usage.output_tokens,
111
+ cacheRead: usage.cache_read_input_tokens,
112
+ cacheWrite: usage.cache_creation_input_tokens,
113
+ })
114
+ }
115
+ for (const block of Array.isArray(message.content) ? message.content : []) {
116
+ if (block?.type !== 'tool_use' || typeof block.name !== 'string') continue
117
+ if (block.id && seenToolUses.has(block.id)) continue
118
+ if (block.id) seenToolUses.add(block.id)
119
+ if (EDIT_TOOLS.has(block.name) && block.id) {
120
+ const folder = typeof entry.cwd === 'string' ? entry.cwd : null
121
+ pendingEdits.set(block.id, { row, project: top ?? folder, input: block.input ?? {}, name: block.name })
122
+ } else countToolUse(row, block.name, block.input ?? {}, desktopNames)
123
+ }
124
+ continue
125
+ }
126
+
127
+ // A user line: a prompt, a stop, or the result of a tool the AI used.
128
+ const content = message.content
129
+ const blocks = Array.isArray(content) ? content : []
130
+ for (const block of blocks) {
131
+ if (block?.type !== 'tool_result') continue
132
+ if (textOf(block.content).includes(REJECTED)) row.rejectedActions += 1
133
+ const edit = pendingEdits.get(block.tool_use_id)
134
+ if (!edit) continue
135
+ pendingEdits.delete(block.tool_use_id)
136
+ // A failed or turned-down edit changed nothing.
137
+ if (!block.is_error) countEdit(edit, entry.toolUseResult)
138
+ }
139
+ // A helper agent's instructions, the app's own notes and a compacted summary are not the person's prompts.
140
+ if (entry.isSidechain || entry.isMeta || entry.isCompactSummary || blocks.some((block) => block?.type === 'tool_result')) continue
141
+ const text = textOf(content).trim()
142
+ if (text.includes(INTERRUPTED)) row.interrupts += 1
143
+ // Slash commands, shell-mode input and the app's notes arrive wrapped in tags such as <command-name>.
144
+ else if ((text && !text.startsWith('<')) || blocks.some((block) => block?.type === 'image')) row.prompts += 1
145
+ }
146
+ }
147
+ }
148
+
149
+ function isInside(path, folder) {
150
+ if (typeof path !== 'string' || !path || !folder) return false
151
+ const file = resolve(path)
152
+ const root = resolve(folder)
153
+ return file === root || file.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
154
+ }
155
+
156
+ /**
157
+ * A finished edit inside the session's project: one file edit, and the lines from the diff Claude Code stored with
158
+ * the result. A new file counts all its lines as added; without a stored diff, the lines are worked out from the edit.
159
+ * @param {{ row: import('./rows.mjs').RowDraft, project: string | null, input: Record<string, any>, name: string }} edit
160
+ * @param {any} result
161
+ */
162
+ function countEdit({ row, project, input, name }, result) {
163
+ const path = (result && typeof result === 'object' && result.filePath) || input.file_path || input.notebook_path
164
+ if (!isInside(path, project)) return
165
+ let added = 0
166
+ let removed = 0
167
+ const patch = result && typeof result === 'object' && Array.isArray(result.structuredPatch) ? result.structuredPatch : null
168
+ if (patch && patch.length > 0) {
169
+ for (const hunk of patch) {
170
+ for (const line of Array.isArray(hunk?.lines) ? hunk.lines : []) {
171
+ if (typeof line !== 'string') continue
172
+ if (line.startsWith('+')) added += 1
173
+ else if (line.startsWith('-')) removed += 1
174
+ }
175
+ }
176
+ } else if (result?.type === 'create' || name === 'Write') {
177
+ added = countLines(typeof result?.content === 'string' ? result.content : input.content)
178
+ } else if (name === 'MultiEdit') {
179
+ for (const step of Array.isArray(input.edits) ? input.edits : []) {
180
+ const change = changedLines(step?.old_string, step?.new_string)
181
+ added += change.added
182
+ removed += change.removed
183
+ }
184
+ } else if (name === 'NotebookEdit') {
185
+ added = countLines(input.new_source)
186
+ } else {
187
+ ;({ added, removed } = changedLines(input.old_string, input.new_string))
188
+ }
189
+ row.fileEdits += 1
190
+ row.linesAdded += added
191
+ row.linesRemoved += removed
192
+ }
193
+
194
+ /** @param {import('./rows.mjs').RowDraft} row @param {string} name @param {Record<string, any>} input @param {Map<string, string>} desktopNames */
195
+ function countToolUse(row, name, input, desktopNames) {
196
+ switch (name) {
197
+ case 'Bash':
198
+ row.commands += 1
199
+ return
200
+ case 'Task':
201
+ case 'Agent':
202
+ row.helperAgents += 1
203
+ return
204
+ case 'Skill':
205
+ row.skills += 1
206
+ return
207
+ default:
208
+ if (name.startsWith('mcp__')) {
209
+ const connector = claudeConnectorName(name, desktopNames)
210
+ if (connector) addConnectorCall(row, connector)
211
+ }
212
+ }
213
+ }