@ai-adoption-score/ai-coding-usage 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,11 +5,46 @@ Score. It counts how a person uses Claude Code and Codex on their Mac and, once
5
5
  company's survey. Prompts, the AI's answers, code, file names, command text and session titles never leave the Mac.
6
6
 
7
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.
8
+ `npx @ai-adoption-score/ai-coding-usage@latest connect <code>`, to paste into Claude Code, Codex or Terminal.
9
9
 
10
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
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".
12
+ report "no AI use today" from "no signal". Once a day the ping-back also looks for a newer version on npm and puts it
13
+ in place, so nobody runs the line again to get it.
14
+
15
+ ## How it works, step by step
16
+
17
+ In short: connect, choose on the survey page, then share. Before the choice, only repository names leave the Mac.
18
+
19
+ 1. **The person pastes the line from their survey page** into Claude Code, Codex or Terminal. The code in it works
20
+ once, for 30 minutes; the server trades it for a token for this Mac.
21
+ 2. **The add-on puts its hooks and the ping-back in place** and copies itself to `~/.ai-adoption-score`. Why: the
22
+ survey gets the numbers after every session without anyone running a command again, and a hook can't run from
23
+ npx's temporary folder. The ping-back carries no numbers; it lets the report tell "no AI use today" from "no signal".
24
+ 3. **It sends the names of the repositories used in the last 30 days, and nothing else.** Why: the person chooses on
25
+ their survey page, and the page needs the names to list them. The choice isn't asked in the terminal, because the
26
+ line usually runs inside Claude Code or Codex, where the add-on can't ask questions. The server keeps the list for
27
+ an hour at most.
28
+ 4. **The person ticks what to share** on their survey page; everything is ticked to start with. When they press
29
+ Share, the list is deleted, and with it the names they didn't tick.
30
+ 5. **Only then do numbers leave the Mac:** the last 30 days of what they ticked, then the new numbers after each
31
+ session. Before every sync the add-on asks what they chose and keeps everything else on the Mac; the server checks
32
+ the same rule again.
33
+ 6. **It keeps itself up to date.** Once a day the ping-back asks npm for the latest version. A newer one is
34
+ downloaded, checked against the checksum npm lists for it, started once to see that it runs on this Mac's Node, and
35
+ put in place of the add-on's copy; the hooks, the ping-back and the settings stay as they are. Why: a fix or a new
36
+ number reaches every Mac without anyone running the line again. It never goes back to an older version, skips a
37
+ version npm marks as withdrawn, and keeps the copy that works when anything fails.
38
+ 7. **It removes itself** when the Mac is disconnected on the survey page or the survey closes. `disconnect` does the
39
+ same from the Mac.
40
+
41
+ The person makes their choice on the survey page, so the survey's server keeps it and the add-on follows the server's
42
+ answer. It still checks what it's told:
43
+
44
+ - It reads 30 days back at most, whatever the server asks for.
45
+ - It shares nothing when the choice isn't one of the two kinds the server keeps: what the person ticked, or
46
+ `{ "all": true }` for links made before people could choose.
47
+ - It sends the token over https only. Plain http works only for this Mac's own address (`localhost`), for testing.
13
48
 
14
49
  ## Commands
15
50
 
@@ -17,13 +52,14 @@ report "no AI use today" from "no signal".
17
52
  hooks and the ping-back in place. Then it sends the names of the repositories used in the last 30 days, and nothing
18
53
  else, so the person can choose on their survey page which to share; a background wait shares the last 30 days of
19
54
  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.
55
+ `AI_ADOPTION_SCORE_SERVER` or `--server http://localhost:3000` point it at a local app; any other server has to be
56
+ https. Run again on a Mac that is connected, it keeps that Mac's link on the same server (the survey page still
57
+ shows one Mac) and lets the person choose again; a link on another server is disconnected, and its token never goes
58
+ to the new one.
23
59
  - `sync`: shares now instead of after the next session. It counts the window again and sends the rows that changed
24
60
  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.
61
+ - `status`: which survey this Mac shares with, what the person chose, the hooks and when they last ran, when it last
62
+ shared, and the add-on's version and when it last looked for a newer one.
27
63
  - `disconnect`: stops sharing from this Mac and removes everything it put in place.
28
64
  - `preview [--days 30] [--company github.com/acme] [--json]`: what would be shared, without sending.
29
65
 
@@ -58,14 +94,15 @@ nothing, and `disconnect` still takes them away.
58
94
 
59
95
  For testing, `AI_ADOPTION_SCORE_HOME`, `AI_ADOPTION_SCORE_CLAUDE_SETTINGS`, `AI_ADOPTION_SCORE_CODEX_HOOKS` and
60
96
  `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.
97
+ alone. `AI_ADOPTION_SCORE_NODE` names a Node for `run.sh` to try. `AI_ADOPTION_SCORE_REGISTRY` points the daily update
98
+ at another npm registry; like the server, it has to be https, or http on this Mac itself.
62
99
 
63
100
  ## Try it
64
101
 
65
102
  Count this Mac's last 30 days and see what would be shared, without connecting and without sending anything:
66
103
 
67
104
  ```bash
68
- npx @ai-adoption-score/ai-coding-usage@1 preview
105
+ npx @ai-adoption-score/ai-coding-usage@latest preview
69
106
  ```
70
107
 
71
108
  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
@@ -102,10 +139,10 @@ again and ticks it. Work outside any repository is one more box.
102
139
  ## What a row holds
103
140
 
104
141
  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.
142
+ moment, the repository and branch and whether the branch is pushed, active minutes and the same time by hour, prompts,
143
+ stops, rejected actions, commands, file edits, lines added and removed, connector calls by name, skills, helper agents,
144
+ commits and pushed commits, and per model its replies and tokens. `npx @ai-adoption-score/ai-coding-usage@latest
145
+ preview --json` prints the exact rows this Mac would send.
109
146
 
110
147
  Never shared: prompts, AI answers, code, file names and paths, command text, session titles.
111
148
 
@@ -125,3 +162,7 @@ Never shared: prompts, AI answers, code, file names and paths, command text, ses
125
162
  lines as added. A failed or turned-down edit, or a scratch file elsewhere, doesn't count.
126
163
  - Active minutes add up the gaps under five minutes. A commit counts for the session whose window holds it, from its
127
164
  first moment to an hour after its last; each commit counts once.
165
+ - Time by hour (`minutesByHour`, from 1.1) is the same active time cut at the hours (UTC). A minute when several
166
+ sessions were active at once is split between them, so a person's rows add up to the time they really spent: on one
167
+ Mac with sessions side by side, 30 days of active minutes came to 185 hours and their time by hour to the 92 really
168
+ spent. Only the rows that are shared take part, so a session that stays on the Mac takes no share.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-adoption-score/ai-coding-usage",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
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
5
  "homepage": "https://ai-adoption-score.app/product/ai-coding-usage",
6
6
  "repository": {
package/src/api.mjs CHANGED
@@ -7,6 +7,22 @@ const TIMEOUT_MS = 20_000
7
7
 
8
8
  export const CLIENT_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
9
9
 
10
+ /** Plain http goes only to this Mac itself, for testing; the device token goes anywhere else over https. */
11
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
12
+
13
+ /** Why the add-on won't talk to `server`, or null when it will. @param {string} server */
14
+ export function serverProblem(server) {
15
+ let url
16
+ try {
17
+ url = new URL(server)
18
+ } catch {
19
+ return `"${server}" isn't a web address.`
20
+ }
21
+ if (url.protocol === 'https:') return null
22
+ if (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname)) return null
23
+ return `"${server}" isn't https. Plain http works only for this Mac's own address (localhost), for testing.`
24
+ }
25
+
10
26
  /**
11
27
  * @param {string} server @param {string} path @param {unknown} body @param {string} [token]
12
28
  * @returns {Promise<{ status: number, body: any, retryAfterSeconds: number | null }>}
@@ -25,6 +41,8 @@ export async function get(server, path, token) {
25
41
 
26
42
  /** @param {string} server @param {string} path @param {'GET' | 'POST'} method @param {unknown} body @param {string} [token] */
27
43
  async function request(server, path, method, body, token) {
44
+ const problem = serverProblem(server)
45
+ if (problem) throw new Error(problem)
28
46
  const controller = new AbortController()
29
47
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
30
48
  try {
package/src/checkin.mjs CHANGED
@@ -8,11 +8,12 @@ import { log } from './log.mjs'
8
8
  import { paths } from './paths.mjs'
9
9
  import { localDay } from './rows.mjs'
10
10
  import { readState, updateState } from './state.mjs'
11
+ import { updateIfDue } from './update.mjs'
11
12
 
12
13
  /**
13
14
  * The hourly check-in (a launchd agent): tells the survey this Mac is there, and whether each tool's hook is still in
14
15
  * 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
+ * without one is a gap. Once a day it also looks for a newer add-on (update.mjs).
16
17
  */
17
18
 
18
19
  const NOT_APPROVED_GRACE_MS = 15 * 60_000
@@ -91,6 +92,12 @@ export async function runCheckIn(env = process.env) {
91
92
  log(`check-in: ${response.status} ${status ?? ''} claude=${checkIn.claudeHook} codex=${checkIn.codexHook}`, env)
92
93
  } catch (error) {
93
94
  log(`check-in failed: ${error instanceof Error ? error.message : String(error)}`, env)
95
+ return 0
96
+ }
97
+ try {
98
+ await updateIfDue(env)
99
+ } catch (error) {
100
+ log(`update failed: ${error instanceof Error ? error.message : String(error)}`, env)
94
101
  }
95
102
  return 0
96
103
  }
package/src/choice.mjs CHANGED
@@ -19,13 +19,33 @@ export function repositoryOwner(repository) {
19
19
  return parts.length >= 3 ? `${parts[0]}/${parts[1]}`.toLowerCase() : null
20
20
  }
21
21
 
22
+ /**
23
+ * The choice in the server's answer, taken only in one of the two shapes the server keeps: what the person ticked, or
24
+ * `{ all: true }` for links made before people could choose. Anything else shares nothing.
25
+ * @param {unknown} value @returns {RepositoryChoice | null}
26
+ */
27
+ export function readChoice(value) {
28
+ if (!value || typeof value !== 'object') return null
29
+ const choice = /** @type {Record<string, unknown>} */ (value)
30
+ if ('all' in choice) return choice.all === true ? { all: true } : null
31
+ const names = (list) => Array.isArray(list) && list.every((name) => typeof name === 'string')
32
+ if (!names(choice.repositories) || !names(choice.owners)) return null
33
+ if (typeof choice.outsideGit !== 'boolean' || typeof choice.localFolders !== 'boolean') return null
34
+ return {
35
+ repositories: /** @type {string[]} */ (choice.repositories),
36
+ owners: /** @type {string[]} */ (choice.owners),
37
+ outsideGit: choice.outsideGit,
38
+ localFolders: choice.localFolders,
39
+ }
40
+ }
41
+
22
42
  /**
23
43
  * Whether a session in `repository` (null outside any repository) is shared. Nothing is, before the person chooses.
24
44
  * @param {RepositoryChoice | null} choice @param {string | null} repository
25
45
  */
26
46
  export function isShared(choice, repository) {
27
47
  if (!choice) return false
28
- if ('all' in choice) return true
48
+ if ('all' in choice) return choice.all === true
29
49
  if (!repository) return choice.outsideGit
30
50
  if (choice.repositories.includes(repository)) return true
31
51
  if (repository.startsWith(LOCAL_PREFIX)) return choice.localFolders
@@ -64,7 +84,7 @@ export function repositoryList(rows) {
64
84
  /** What this Mac shares, in a line for `status`. @param {RepositoryChoice | null} choice */
65
85
  export function describeChoice(choice) {
66
86
  if (!choice) return 'nothing yet: choose your repositories on your survey page'
67
- if ('all' in choice) return 'every repository'
87
+ if ('all' in choice) return choice.all === true ? 'every repository' : 'nothing'
68
88
  const parts = [`${choice.repositories.length} ${choice.repositories.length === 1 ? 'repository' : 'repositories'}`]
69
89
  if (choice.owners.length > 0) parts.push(`new ones from ${choice.owners.join(', ')}`)
70
90
  if (choice.outsideGit) parts.push('work outside a repository')
package/src/cli.mjs CHANGED
@@ -4,13 +4,14 @@ import { existsSync, rmSync } from 'node:fs'
4
4
  import { homedir } from 'node:os'
5
5
  import { dirname, join } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
- import { CLIENT_VERSION, post } from './api.mjs'
7
+ import { CLIENT_VERSION, post, serverProblem } from './api.mjs'
8
8
  import { runCheckIn } from './checkin.mjs'
9
9
  import { DEFAULT_SERVER, readConfig, writeConfig } from './config.mjs'
10
10
  import { runHook } from './hook.mjs'
11
11
  import { hookState, installAll, uninstallAll } from './install.mjs'
12
12
  import { paths } from './paths.mjs'
13
- import { scanUsage } from './scan.mjs'
13
+ import { daysToRead, scanUsage } from './scan.mjs'
14
+ import { timeByHour } from './rows.mjs'
14
15
  import { readState } from './state.mjs'
15
16
  import { describeChoice, repositoryList } from './choice.mjs'
16
17
  import { formatSummary } from './summary.mjs'
@@ -29,8 +30,9 @@ Usage
29
30
  connect Link this Mac to your survey with the code from your survey page. It adds a hook to Claude Code and
30
31
  Codex and an automatic ping-back, and sends the names of the repositories you used in the last 30 days, so
31
32
  you can choose on your survey page which to share. Then it shares the last 30 days of those, and after
32
- that, after each session. Run it again to change your choice.
33
- status Show which survey this Mac shares with, what you chose, its hooks, and when it last shared.
33
+ that, after each session. Run it again to change your choice. Once a day the ping-back looks for a
34
+ newer version of the add-on and puts it in place.
35
+ status Show which survey this Mac shares with, what you chose, its hooks, when it last shared, and its version.
34
36
  sync Share now, instead of after the next session.
35
37
  disconnect Stop sharing from this Mac: removes the hooks, the ping-back and the add-on's settings.
36
38
  preview Count this Mac's last 30 days and show what would be shared. Nothing is sent.
@@ -108,12 +110,9 @@ async function connect(args) {
108
110
  else if (!code) code = args[index]
109
111
  else return fail(`Unknown option "${args[index]}".`)
110
112
  }
111
- if (!code) return fail('Add the code from your survey page: npx @ai-adoption-score/ai-coding-usage@1 connect K7Q2-9M4X')
112
- try {
113
- new URL(server)
114
- } catch {
115
- return fail(`"${server}" isn't a web address.`)
116
- }
113
+ if (!code) return fail('Add the code from your survey page: npx @ai-adoption-score/ai-coding-usage@latest connect K7Q2-9M4X')
114
+ const problem = serverProblem(server)
115
+ if (problem) return fail(problem)
117
116
  // Run again on a Mac that is connected: the same server carries the link on with the new token, so it doesn't
118
117
  // count a second Mac. A token never goes to another server; that survey is told the Mac left once this one connects.
119
118
  const previous = readConfig()
@@ -131,7 +130,9 @@ async function connect(args) {
131
130
  if (response.status !== 200 || response.body?.status !== 'connected') {
132
131
  return fail(response.body?.error ?? `Couldn't connect: the server answered ${response.status}.`)
133
132
  }
134
- const { token, companyName, syncIntervalSeconds, historyDays } = response.body
133
+ const { token, companyName, syncIntervalSeconds } = response.body
134
+ // However far back the survey asks for, the add-on reads 30 days at most.
135
+ const historyDays = daysToRead(response.body.historyDays)
135
136
  // A new connection starts with nothing sent.
136
137
  rmSync(paths().state, { force: true })
137
138
  const config = { server, token, companyName, connectedAt: new Date().toISOString(), syncIntervalSeconds, historyDays }
@@ -149,7 +150,7 @@ async function connect(args) {
149
150
  say(` Codex: ${HOOK_WORDS[installed.codex] ?? installed.codex}`)
150
151
  say(` Automatic ping-back: ${installed.checkIn === 'added' ? 'added, so the survey knows this Mac is still connected (no numbers)' : "couldn't add it"}`)
151
152
  // The person chooses which repositories to share before any session leaves this Mac: only the names go now.
152
- const scan = await scanUsage({ days: historyDays || 30 })
153
+ const scan = await scanUsage({ days: historyDays })
153
154
  const list = repositoryList(scan.rows)
154
155
  const listed = await post(server, '/api/usage/repositories', list, token)
155
156
  if (listed.body?.status !== 'choosing') {
@@ -227,6 +228,7 @@ async function status() {
227
228
  say(`Codex hook: ${hookState('codex')}${state.hookRuns.codex ? `, last ran ${state.hookRuns.codex}` : ''}`)
228
229
  say(state.lastSyncAt ? `Last shared: ${state.lastSyncAt}.` : 'Nothing shared yet.')
229
230
  if (state.lastCheckInAt) say(`Last ping-back: ${state.lastCheckInAt}.`)
231
+ say(`Add-on ${CLIENT_VERSION}; it looks for a newer version once a day${state.lastUpdateCheckAt ? `, last on ${state.lastUpdateCheckAt}` : ''}.`)
230
232
  if (state.lastError) say(`Last problem: ${state.lastError}`)
231
233
  return 0
232
234
  }
@@ -266,6 +268,6 @@ async function preview(rest) {
266
268
  }
267
269
  if (process.platform !== 'darwin') return fail('AI Coding Usage works on a Mac for now. Nothing was read.')
268
270
  const scan = await scanUsage({ days })
269
- process.stdout.write(json ? `${JSON.stringify({ since: scan.since, until: scan.until, rows: scan.rows }, null, 2)}\n` : `${formatSummary(scan, { companyAccounts })}\n`)
271
+ process.stdout.write(json ? `${JSON.stringify({ since: scan.since, until: scan.until, rows: timeByHour(scan.rows) }, null, 2)}\n` : `${formatSummary(scan, { companyAccounts })}\n`)
270
272
  return 0
271
273
  }
package/src/rows.mjs CHANGED
@@ -12,7 +12,8 @@ import { createHash } from 'node:crypto'
12
12
  * repository: string | null, branch: string | null, branchPushed: boolean, activeMinutes: number,
13
13
  * prompts: number, interrupts: number, rejectedActions: number, commands: number, fileEdits: number,
14
14
  * linesAdded: number, linesRemoved: number, connectors: Record<string, number>, skills: number,
15
- * helperAgents: number, commits: number, commitsPushed: number, models: Record<string, ModelTokens>
15
+ * helperAgents: number, commits: number, commitsPushed: number, models: Record<string, ModelTokens>,
16
+ * minutesByHour?: Record<string, number>
16
17
  * }} UsageRow
17
18
  *
18
19
  * What a row collects before it is finished; `gitTop` and `times` never leave the Mac.
@@ -111,6 +112,57 @@ export function changedLines(before, after) {
111
112
  return { added: newEnd - start, removed: oldEnd - start }
112
113
  }
113
114
 
115
+ /** Each finished row's moments, kept here so they never leave the Mac: `timeByHour` needs them. */
116
+ const momentsOf = new WeakMap()
117
+ const HOUR_MS = 3_600_000
118
+
119
+ /**
120
+ * Time with AI by hour (owner, 2026-10-30): each row's active stretches, the gaps under five minutes between its
121
+ * moments, cut at the hours (UTC, `YYYY-MM-DDTHH`), with a stretch when several of `rows` were active at once shared
122
+ * evenly between them. So the rows of a day add up to the time the person really spent with AI, however many sessions
123
+ * ran side by side, where active minutes count each session's own. Only the rows given take part, so a session that
124
+ * isn't shared changes nothing in the ones that are.
125
+ * @param {UsageRow[]} rows @returns {UsageRow[]}
126
+ */
127
+ export function timeByHour(rows) {
128
+ /** @type {Array<[number, number, number]>} */
129
+ const points = []
130
+ rows.forEach((row, index) => {
131
+ const times = momentsOf.get(row) ?? []
132
+ for (let at = 1; at < times.length; at += 1) {
133
+ if (times[at] > times[at - 1] && times[at] - times[at - 1] < ACTIVE_GAP_MS) points.push([times[at - 1], 1, index], [times[at], -1, index])
134
+ }
135
+ })
136
+ // At the same moment, a stretch ends before the next begins.
137
+ points.sort((left, right) => left[0] - right[0] || left[1] - right[1])
138
+ /** @type {Map<string, number>[]} */
139
+ const shares = rows.map(() => new Map())
140
+ /** @type {Map<number, number>} the rows active now, and how many of their stretches */
141
+ const open = new Map()
142
+ let previous = 0
143
+ for (const [time, step, index] of points) {
144
+ for (let from = previous; open.size > 0 && from < time; ) {
145
+ const to = Math.min((Math.floor(from / HOUR_MS) + 1) * HOUR_MS, time)
146
+ const hour = new Date(from).toISOString().slice(0, 13)
147
+ for (const active of open.keys()) shares[active].set(hour, (shares[active].get(hour) ?? 0) + (to - from) / open.size)
148
+ from = to
149
+ }
150
+ const count = (open.get(index) ?? 0) + step
151
+ if (count > 0) open.set(index, count)
152
+ else open.delete(index)
153
+ previous = time
154
+ }
155
+ return rows.map((row, index) => {
156
+ /** @type {Record<string, number>} */
157
+ const minutesByHour = {}
158
+ for (const [hour, ms] of shares[index]) {
159
+ const minutes = Math.round(ms / 6_000) / 10
160
+ if (minutes > 0) minutesByHour[hour] = minutes
161
+ }
162
+ return { ...row, minutesByHour }
163
+ })
164
+ }
165
+
114
166
  /** Working time: the gaps between a row's moments that are shorter than five minutes, added up. */
115
167
  function activeMinutes(times) {
116
168
  if (times.length === 0) return 0
@@ -130,7 +182,7 @@ function activeMinutes(times) {
130
182
  export function finishRow(draft, isPushed) {
131
183
  const times = [...draft.times].sort((left, right) => left - right)
132
184
  const { sessionId, gitTop, times: _times, ...rest } = draft
133
- return {
185
+ const row = {
134
186
  ...rest,
135
187
  sessionKey: createHash('sha256').update(`${draft.tool}:${sessionId}`).digest('hex').slice(0, 32),
136
188
  firstEventAt: new Date(times[0]).toISOString(),
@@ -138,6 +190,8 @@ export function finishRow(draft, isPushed) {
138
190
  branchPushed: isPushed(gitTop, draft.branch),
139
191
  activeMinutes: activeMinutes(times),
140
192
  }
193
+ momentsOf.set(row, times)
194
+ return row
141
195
  }
142
196
 
143
197
  /** Whether a row has anything worth sending: a reply from the AI or a prompt from the person. */
package/src/scan.mjs CHANGED
@@ -5,13 +5,22 @@ import { loadDesktopConnectorNames } from './connectors.mjs'
5
5
  import { assignCommits, createGitInfo } from './git.mjs'
6
6
  import { createRowSet, finishRow, hasUse, windowStart } from './rows.mjs'
7
7
 
8
+ /** The add-on never reads further back than this, whatever the server asks for. */
9
+ export const MAX_HISTORY_DAYS = 30
10
+
11
+ /** How many days to read: a whole number from 1 to 30, and 30 when `days` isn't one. @param {unknown} days */
12
+ export function daysToRead(days) {
13
+ const whole = Math.trunc(Number(days))
14
+ return Number.isFinite(whole) && whole >= 1 ? Math.min(whole, MAX_HISTORY_DAYS) : MAX_HISTORY_DAYS
15
+ }
16
+
8
17
  /**
9
- * Reads this Mac's Claude Code and Codex records for the last `days` calendar days and returns the rows that would be
10
- * sent, with how much of the records could be read. Nothing is written and nothing leaves the Mac.
18
+ * Reads this Mac's Claude Code and Codex records for the last `days` calendar days, 30 at most, and returns the rows
19
+ * that would be sent, with how much of the records could be read. Nothing is written and nothing leaves the Mac.
11
20
  * @param {{ days?: number, now?: number, env?: NodeJS.ProcessEnv }} [options]
12
21
  */
13
- export async function scanUsage({ days = 30, now = Date.now(), env = process.env } = {}) {
14
- const since = windowStart(now, days)
22
+ export async function scanUsage({ days = MAX_HISTORY_DAYS, now = Date.now(), env = process.env } = {}) {
23
+ const since = windowStart(now, daysToRead(days))
15
24
  const rows = createRowSet()
16
25
  const gitInfo = createGitInfo()
17
26
  const claudeHealth = { files: 0, lines: 0, unreadable: 0 }
package/src/state.mjs CHANGED
@@ -5,13 +5,14 @@ import { paths } from './paths.mjs'
5
5
 
6
6
  /**
7
7
  * What the add-on remembers between runs, beside its settings: which rows the server accepted (by a hash of each), when
8
- * it last synced, when each tool's hook last ran, and how long the server asked it to wait. Only names and numbers.
8
+ * it last synced, when each tool's hook last ran, how long the server asked it to wait, and when it last looked for a
9
+ * newer version. Only names and numbers.
9
10
  *
10
11
  * @typedef {{
11
12
  * sent: Record<string, string>,
12
13
  * lastSyncStartedAt?: string, lastSyncAt?: string, notBefore?: string,
13
14
  * hookRuns: Partial<Record<'claude-code' | 'codex', string>>,
14
- * lastCheckInAt?: string, lastError?: string
15
+ * lastCheckInAt?: string, lastError?: string, lastUpdateCheckAt?: string
15
16
  * }} UsageState
16
17
  */
17
18
 
package/src/sync.mjs CHANGED
@@ -4,12 +4,12 @@ import { closeSync, openSync, readFileSync, rmSync, writeSync } from 'node:fs'
4
4
  import { constants, setPriority } from 'node:os'
5
5
  import { get, post, CLIENT_VERSION } from './api.mjs'
6
6
  import { reportedHookState } from './checkin.mjs'
7
- import { isShared } from './choice.mjs'
7
+ import { isShared, readChoice } from './choice.mjs'
8
8
  import { isCurrentConfig, writeConfig } from './config.mjs'
9
9
  import { hookState, uninstallAll } from './install.mjs'
10
10
  import { log } from './log.mjs'
11
11
  import { paths } from './paths.mjs'
12
- import { localDay } from './rows.mjs'
12
+ import { localDay, timeByHour } from './rows.mjs'
13
13
  import { scanUsage } from './scan.mjs'
14
14
  import { readState, updateState } from './state.mjs'
15
15
 
@@ -96,7 +96,7 @@ export async function askChoice(config) {
96
96
  if (response.status === 429) return { status: 'waiting', choice: null, retryAfterSeconds: response.retryAfterSeconds }
97
97
  const status = response.body?.status ?? (response.status === 401 ? 'revoked' : 'error')
98
98
  if (status === 'error') throw new Error(response.body?.error ?? `The server answered ${response.status}.`)
99
- return { status, choice: response.body?.choice ?? null, retryAfterSeconds: null }
99
+ return { status, choice: readChoice(response.body?.choice), retryAfterSeconds: null }
100
100
  }
101
101
 
102
102
  /**
@@ -136,8 +136,9 @@ export async function syncNow(config, { background = false, reason = 'manual', e
136
136
  }
137
137
  const { choice } = asked
138
138
 
139
- const scan = await scanUsage({ days: config.historyDays || 30, env })
140
- const included = scan.rows.filter((row) => sendsTool(row.tool, config, state, env) && isShared(choice, row.repository))
139
+ const scan = await scanUsage({ days: config.historyDays, env })
140
+ // Time by hour among the rows that go out only, so a session that stays on the Mac takes no share of it.
141
+ const included = timeByHour(scan.rows.filter((row) => sendsTool(row.tool, config, state, env) && isShared(choice, row.repository)))
141
142
  const changed = included.filter((row) => state.sent[rowKey(row)] !== rowHash(row))
142
143
  const checkIn = {
143
144
  day: localDay(Date.now()),
package/src/update.mjs ADDED
@@ -0,0 +1,173 @@
1
+ // @ts-check
2
+ import { execFileSync } from 'node:child_process'
3
+ import { createHash } from 'node:crypto'
4
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { gunzipSync } from 'node:zlib'
7
+ import { CLIENT_VERSION, serverProblem } from './api.mjs'
8
+ import { log } from './log.mjs'
9
+ import { paths } from './paths.mjs'
10
+ import { readState, updateState } from './state.mjs'
11
+
12
+ /**
13
+ * The add-on keeps itself up to date (owner, 2026-10-30): once a day the ping-back asks npm for the latest version.
14
+ * When there is a newer one, it downloads it, checks it is byte for byte the file npm lists for that version, checks
15
+ * that it starts on this Mac's Node, and puts it in place of its own copy; the next hook or ping-back runs it. Nothing
16
+ * is sent to npm but the request itself. The hooks, the ping-back and the settings stay as they are, and anything that
17
+ * goes wrong leaves the copy that works.
18
+ */
19
+
20
+ const PACKAGE = '@ai-adoption-score/ai-coding-usage'
21
+ const REGISTRY = 'https://registry.npmjs.org'
22
+ const CHECK_EVERY_MS = 24 * 60 * 60_000
23
+ const TIMEOUT_MS = 30_000
24
+ const MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024
25
+
26
+ /** "1.2.3" as numbers; null for anything else, such as a pre-release. @param {unknown} version */
27
+ function versionParts(version) {
28
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version))
29
+ return match ? match.slice(1).map(Number) : null
30
+ }
31
+
32
+ /** Whether `candidate` is a later release than `current`; never a step back. @param {unknown} candidate @param {unknown} current */
33
+ export function isNewer(candidate, current) {
34
+ const next = versionParts(candidate)
35
+ const now = versionParts(current)
36
+ if (!next || !now) return false
37
+ for (let index = 0; index < 3; index += 1) if (next[index] !== now[index]) return next[index] > now[index]
38
+ return false
39
+ }
40
+
41
+ /** Whether this Node is new enough for `engines.node` written as ">=18.17"; any other form is left to the start check. */
42
+ function nodeFits(engines, running = process.versions.node) {
43
+ const wanted = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(String(engines?.node ?? '').trim())
44
+ if (!wanted) return true
45
+ const need = wanted.slice(1).map((part) => Number(part ?? 0))
46
+ const have = running.split('.').map(Number)
47
+ for (let index = 0; index < 3; index += 1) if ((have[index] ?? 0) !== need[index]) return (have[index] ?? 0) > need[index]
48
+ return true
49
+ }
50
+
51
+ /** @param {string} url @param {Record<string, string>} headers */
52
+ async function download(url, headers = {}) {
53
+ const controller = new AbortController()
54
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
55
+ try {
56
+ const response = await fetch(url, { headers: { 'user-agent': `ai-coding-usage/${CLIENT_VERSION}`, ...headers }, signal: controller.signal })
57
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`)
58
+ const body = Buffer.from(await response.arrayBuffer())
59
+ if (body.length > MAX_DOWNLOAD_BYTES) throw new Error(`${url} is larger than expected`)
60
+ return body
61
+ } finally {
62
+ clearTimeout(timer)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The files of an npm package: a gzipped tar with every entry under `package/`. Only plain files whose path stays
68
+ * inside the package are read; links and anything else are left out.
69
+ * @param {Buffer} tarball @returns {Map<string, { data: Buffer, mode: number }>}
70
+ */
71
+ export function readPackage(tarball) {
72
+ const archive = gunzipSync(tarball)
73
+ const files = new Map()
74
+ /** @type {string | null} */
75
+ let paxPath = null
76
+ for (let offset = 0; offset + 512 <= archive.length; ) {
77
+ const header = archive.subarray(offset, offset + 512)
78
+ if (header.every((byte) => byte === 0)) break
79
+ const field = (start, length) => header.subarray(start, start + length).toString('utf8').replace(/\0[\s\S]*$/, '')
80
+ const size = parseInt(field(124, 12).trim() || '0', 8)
81
+ const type = field(156, 1) || '0'
82
+ const prefix = field(257, 5) === 'ustar' ? field(345, 155) : ''
83
+ const name = paxPath ?? (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100))
84
+ paxPath = null
85
+ const body = archive.subarray(offset + 512, offset + 512 + size)
86
+ offset += 512 + Math.ceil(size / 512) * 512
87
+ if (type === 'x') {
88
+ // An extended header: its `path` names the next entry.
89
+ paxPath = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(body.toString('utf8'))?.[1] ?? null
90
+ continue
91
+ }
92
+ if (type !== '0') continue
93
+ const parts = name.split('/')
94
+ if (parts[0] !== 'package' || parts.length < 2 || parts.some((part) => part === '' || part === '.' || part === '..')) continue
95
+ files.set(parts.slice(1).join('/'), { data: Buffer.from(body), mode: parseInt(field(100, 8).trim() || '644', 8) & 0o755 })
96
+ }
97
+ return files
98
+ }
99
+
100
+ /**
101
+ * Puts `version` in place of the add-on's copy, when it is the one npm lists and it starts here.
102
+ * @param {{ version: string, dist?: { tarball?: string, integrity?: string } }} manifest @param {string} registry
103
+ */
104
+ async function install(manifest, registry, env) {
105
+ const where = paths(env)
106
+ const tarball = manifest.dist?.tarball
107
+ const integrity = /^sha512-(.+)$/.exec(manifest.dist?.integrity ?? '')?.[1]
108
+ if (!tarball || !integrity || new URL(tarball).origin !== new URL(registry).origin) throw new Error(`npm lists no file for ${manifest.version} that can be checked`)
109
+ const archive = await download(tarball)
110
+ if (createHash('sha512').update(archive).digest('base64') !== integrity) throw new Error(`the file for ${manifest.version} isn't the one npm lists`)
111
+ const files = readPackage(archive)
112
+ const listed = JSON.parse(files.get('package.json')?.data.toString('utf8') ?? '{}')
113
+ if (listed.name !== PACKAGE || listed.version !== manifest.version || !files.has('bin/ai-coding-usage.mjs')) throw new Error(`the file for ${manifest.version} isn't this add-on`)
114
+
115
+ // The new copy beside the old one, as connecting puts it: its program and its package.json, nothing else.
116
+ const fresh = `${where.app}.update`
117
+ const previous = `${where.app}.previous`
118
+ rmSync(fresh, { recursive: true, force: true })
119
+ try {
120
+ for (const [path, file] of files) {
121
+ if (path !== 'package.json' && !path.startsWith('bin/') && !path.startsWith('src/')) continue
122
+ mkdirSync(dirname(join(fresh, path)), { recursive: true })
123
+ writeFileSync(join(fresh, path), file.data, { mode: file.mode })
124
+ }
125
+ try {
126
+ // It has to start on this Node before it replaces the copy that works: its help loads all of it and exits.
127
+ execFileSync(process.execPath, [join(fresh, 'bin', 'ai-coding-usage.mjs'), 'help'], { stdio: 'ignore', timeout: TIMEOUT_MS, env })
128
+ } catch {
129
+ throw new Error(`${manifest.version} doesn't start on Node ${process.versions.node}`)
130
+ }
131
+ rmSync(previous, { recursive: true, force: true })
132
+ renameSync(where.app, previous)
133
+ try {
134
+ renameSync(fresh, where.app)
135
+ } catch (error) {
136
+ renameSync(previous, where.app)
137
+ throw error
138
+ }
139
+ rmSync(previous, { recursive: true, force: true })
140
+ } finally {
141
+ rmSync(fresh, { recursive: true, force: true })
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The ping-back's daily look at npm. `AI_ADOPTION_SCORE_REGISTRY` points it at another registry, for testing; like the
147
+ * survey's server, it has to be https, or http on this Mac itself.
148
+ * @returns {Promise<'not-due' | 'up-to-date' | 'updated' | 'skipped'>}
149
+ */
150
+ export async function updateIfDue(env = process.env, now = Date.now()) {
151
+ const last = readState(env).lastUpdateCheckAt
152
+ if (last && now - Date.parse(last) < CHECK_EVERY_MS) return 'not-due'
153
+ const registry = env.AI_ADOPTION_SCORE_REGISTRY || REGISTRY
154
+ const problem = serverProblem(registry)
155
+ if (problem) throw new Error(problem)
156
+ const document = JSON.parse(
157
+ (await download(`${registry.replace(/\/+$/, '')}/${PACKAGE.replace('/', '%2f')}`, { accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8' })).toString('utf8')
158
+ )
159
+ // npm answered: the next look is tomorrow, whatever happens next.
160
+ updateState((current) => {
161
+ current.lastUpdateCheckAt = new Date(now).toISOString()
162
+ }, env)
163
+ const latest = document?.['dist-tags']?.latest
164
+ const manifest = document?.versions?.[latest]
165
+ if (!isNewer(latest, CLIENT_VERSION) || !manifest) return 'up-to-date'
166
+ if (manifest.deprecated || !nodeFits(manifest.engines)) {
167
+ log(`update: ${latest} is out, but ${manifest.deprecated ? 'it was withdrawn' : `it needs Node ${manifest.engines.node}`}; staying on ${CLIENT_VERSION}`, env)
168
+ return 'skipped'
169
+ }
170
+ await install(manifest, registry, env)
171
+ log(`update: ${CLIENT_VERSION} → ${latest}`, env)
172
+ return 'updated'
173
+ }