@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 +21 -0
- package/README.md +126 -2
- package/bin/ai-coding-usage.mjs +13 -0
- package/package.json +36 -4
- package/src/api.mjs +52 -0
- package/src/checkin.mjs +96 -0
- package/src/choice.mjs +72 -0
- package/src/claude-code.mjs +213 -0
- package/src/cli.mjs +271 -0
- package/src/codex.mjs +204 -0
- package/src/config.mjs +56 -0
- package/src/connectors.mjs +108 -0
- package/src/git.mjs +151 -0
- package/src/hook.mjs +73 -0
- package/src/install.mjs +273 -0
- package/src/log.mjs +19 -0
- package/src/paths.mjs +32 -0
- package/src/rows.mjs +146 -0
- package/src/scan.mjs +35 -0
- package/src/state.mjs +43 -0
- package/src/summary.mjs +120 -0
- package/src/sync.mjs +236 -0
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
|
-
#
|
|
1
|
+
# @ai-adoption-score/ai-coding-usage
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/checkin.mjs
ADDED
|
@@ -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
|
+
}
|