clauddy 1.29.0 → 2.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/README.md +40 -339
- package/bin/clauddy.js +82 -79
- package/package.json +10 -82
- package/accounts.js +0 -181
- package/auth.js +0 -300
- package/codex.js +0 -236
- package/config.json +0 -11
- package/cursor.js +0 -278
- package/main.js +0 -1305
- package/preload.js +0 -43
- package/reminders.js +0 -54
- package/renderer/burn.js +0 -98
- package/renderer/companion.js +0 -52
- package/renderer/index.html +0 -527
- package/renderer/motion.css +0 -646
- package/renderer/pet.js +0 -2394
- package/renderer/pets.css +0 -916
- package/renderer/pets.js +0 -579
- package/renderer/style.css +0 -3758
- package/renderer/voice.js +0 -244
- package/usage.js +0 -539
package/codex.js
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
const fs = require('node:fs')
|
|
2
|
-
const path = require('node:path')
|
|
3
|
-
const os = require('node:os')
|
|
4
|
-
|
|
5
|
-
// Codex (CLI and the desktop app alike) writes one `rollout-*.jsonl` per
|
|
6
|
-
// session under ~/.codex/sessions/YYYY/MM/DD. Its `token_count` events carry
|
|
7
|
-
// both the token totals and the account's rate limits, so everything here comes
|
|
8
|
-
// from local files — no login needed.
|
|
9
|
-
let codexDir = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
10
|
-
|
|
11
|
-
function setCodexDir(dir) {
|
|
12
|
-
codexDir = dir || process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
13
|
-
fileCache.clear()
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const DAY = 86400000
|
|
17
|
-
const DAYS = 30
|
|
18
|
-
const WEEK = 7 * DAY
|
|
19
|
-
const ACTIVE_MS = 60000 // a rollout touched within the last minute = Codex working
|
|
20
|
-
const DAY_MIN = 24 * 60
|
|
21
|
-
const TOP_PROJECTS = 5 // same fold as Claude's panel
|
|
22
|
-
|
|
23
|
-
// per file: how far we've read, plus what we got out of it. Rollouts only ever
|
|
24
|
-
// grow, so a poll reads just the bytes appended since the last one.
|
|
25
|
-
const fileCache = new Map()
|
|
26
|
-
|
|
27
|
-
function blankSummary() {
|
|
28
|
-
return { offset: 0, rest: '', lastTotal: 0, events: [], limits: null, model: null, cwd: null }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// a token_count's rate limits. Codex reports several buckets; some (the
|
|
32
|
-
// `premium` one on Plus, say) come with null windows — only a bucket with a
|
|
33
|
-
// real window is worth showing.
|
|
34
|
-
function limitsOf(rl, ts) {
|
|
35
|
-
if (!rl || (!rl.primary && !rl.secondary)) return null
|
|
36
|
-
const win = (w) =>
|
|
37
|
-
w && typeof w.used_percent === 'number'
|
|
38
|
-
? { pct: w.used_percent, resetsAt: w.resets_at ? w.resets_at * 1000 : null }
|
|
39
|
-
: null
|
|
40
|
-
// Plus reports a 5-hour window and a weekly one; some plans (Pro Lite, say)
|
|
41
|
-
// only the weekly one, in the `primary` slot. Sort by length, not by slot.
|
|
42
|
-
let session = null
|
|
43
|
-
let weekly = null
|
|
44
|
-
for (const w of [rl.primary, rl.secondary]) {
|
|
45
|
-
const v = win(w)
|
|
46
|
-
if (!v) continue
|
|
47
|
-
const long = w.window_minutes != null ? w.window_minutes >= DAY_MIN : w === rl.secondary
|
|
48
|
-
if (long) weekly = v
|
|
49
|
-
else session = v
|
|
50
|
-
}
|
|
51
|
-
return { ts, plan: rl.plan_type || null, session, weekly }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function ingestLine(sum, line) {
|
|
55
|
-
// cheap filter before JSON.parse — rollouts are mostly message bodies
|
|
56
|
-
if (!line.includes('"token_count"') && !line.includes('"turn_context"')) return
|
|
57
|
-
let o
|
|
58
|
-
try {
|
|
59
|
-
o = JSON.parse(line)
|
|
60
|
-
} catch {
|
|
61
|
-
return
|
|
62
|
-
}
|
|
63
|
-
const p = o.payload || {}
|
|
64
|
-
const ts = Date.parse(o.timestamp) || 0
|
|
65
|
-
if (o.type === 'turn_context') {
|
|
66
|
-
if (p.model) sum.model = p.model
|
|
67
|
-
if (p.cwd) sum.cwd = p.cwd
|
|
68
|
-
return
|
|
69
|
-
}
|
|
70
|
-
if (p.type !== 'token_count') return
|
|
71
|
-
// totals are cumulative per session, and the same count is often emitted
|
|
72
|
-
// twice — the delta is what this event actually spent. The model and project
|
|
73
|
-
// are the ones in effect *now*, so a mid-session switch is attributed right.
|
|
74
|
-
const total = p.info?.total_token_usage?.total_tokens
|
|
75
|
-
if (typeof total === 'number' && total > sum.lastTotal) {
|
|
76
|
-
sum.events.push({ ts, tokens: total - sum.lastTotal, model: sum.model, cwd: sum.cwd })
|
|
77
|
-
sum.lastTotal = total
|
|
78
|
-
}
|
|
79
|
-
const lim = limitsOf(p.rate_limits, ts)
|
|
80
|
-
if (lim && (!sum.limits || lim.ts >= sum.limits.ts)) sum.limits = lim
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function readFile(file, st) {
|
|
84
|
-
let sum = fileCache.get(file)
|
|
85
|
-
// shrunk or replaced: start over
|
|
86
|
-
if (!sum || st.size < sum.offset) sum = blankSummary()
|
|
87
|
-
if (st.size > sum.offset) {
|
|
88
|
-
const fd = fs.openSync(file, 'r')
|
|
89
|
-
try {
|
|
90
|
-
const buf = Buffer.alloc(st.size - sum.offset)
|
|
91
|
-
fs.readSync(fd, buf, 0, buf.length, sum.offset)
|
|
92
|
-
sum.offset = st.size
|
|
93
|
-
const lines = (sum.rest + buf.toString('utf8')).split('\n')
|
|
94
|
-
// the last piece may be a line still being written
|
|
95
|
-
sum.rest = lines.pop()
|
|
96
|
-
for (const line of lines) ingestLine(sum, line)
|
|
97
|
-
} finally {
|
|
98
|
-
fs.closeSync(fd)
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
sum.mtime = st.mtimeMs
|
|
102
|
-
fileCache.set(file, sum)
|
|
103
|
-
return sum
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// every rollout, with its stat; `since` drops the ones untouched since then
|
|
107
|
-
function rollouts(since) {
|
|
108
|
-
const out = []
|
|
109
|
-
const walk = (dir, depth) => {
|
|
110
|
-
let kids
|
|
111
|
-
try {
|
|
112
|
-
kids = fs.readdirSync(dir, { withFileTypes: true })
|
|
113
|
-
} catch {
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
for (const k of kids) {
|
|
117
|
-
const full = path.join(dir, k.name)
|
|
118
|
-
if (k.isDirectory() && depth < 3) walk(full, depth + 1)
|
|
119
|
-
else if (k.isFile() && k.name.endsWith('.jsonl')) {
|
|
120
|
-
try {
|
|
121
|
-
const st = fs.statSync(full)
|
|
122
|
-
if (st.mtimeMs >= since) out.push({ file: full, st })
|
|
123
|
-
} catch {}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
walk(path.join(codexDir, 'sessions'), 0)
|
|
128
|
-
return out
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Is there a Codex session on this machine? The folder alone is not enough.
|
|
132
|
-
function detectCodex() {
|
|
133
|
-
const files = rollouts(0)
|
|
134
|
-
if (!files.length) return { found: false, plan: null }
|
|
135
|
-
files.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs)
|
|
136
|
-
let plan = null
|
|
137
|
-
for (const { file, st } of files.slice(0, 5)) {
|
|
138
|
-
const lim = readFile(file, st).limits
|
|
139
|
-
if (lim?.plan) {
|
|
140
|
-
plan = lim.plan
|
|
141
|
-
break
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return { found: true, plan }
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// A window whose reset has passed tells us nothing about today: the log is
|
|
148
|
-
// older than the window, so the % is unknown until Codex writes a fresh one.
|
|
149
|
-
function windowNow(w, now) {
|
|
150
|
-
if (!w) return null
|
|
151
|
-
const expired = w.resetsAt != null && w.resetsAt <= now
|
|
152
|
-
return {
|
|
153
|
-
pct: expired ? null : w.pct,
|
|
154
|
-
resetMs: w.resetsAt != null && !expired ? w.resetsAt - now : null,
|
|
155
|
-
expired,
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function ranked(map) {
|
|
160
|
-
return [...map.entries()]
|
|
161
|
-
.map(([label, tokens]) => ({ label, tokens }))
|
|
162
|
-
.sort((a, b) => b.tokens - a.tokens)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function getCodexUsage(now = Date.now()) {
|
|
166
|
-
if (!fs.existsSync(path.join(codexDir, 'sessions'))) return null
|
|
167
|
-
const startOfDay = new Date(now).setHours(0, 0, 0, 0)
|
|
168
|
-
const start30 = startOfDay - (DAYS - 1) * DAY
|
|
169
|
-
const files = rollouts(start30)
|
|
170
|
-
const seen = new Set()
|
|
171
|
-
let limits = null
|
|
172
|
-
let latest = null
|
|
173
|
-
let tokensToday = 0
|
|
174
|
-
let tokens5h = 0
|
|
175
|
-
let tokensWeek = 0
|
|
176
|
-
let monthTokens = 0
|
|
177
|
-
const days30 = new Array(DAYS).fill(0)
|
|
178
|
-
const byModel = new Map()
|
|
179
|
-
const byProject = new Map()
|
|
180
|
-
for (const { file, st } of files) {
|
|
181
|
-
seen.add(file)
|
|
182
|
-
const sum = readFile(file, st)
|
|
183
|
-
if (sum.limits && (!limits || sum.limits.ts > limits.ts)) limits = sum.limits
|
|
184
|
-
if (!latest || sum.mtime > latest.mtime) latest = sum
|
|
185
|
-
for (const e of sum.events) {
|
|
186
|
-
if (e.ts < start30) continue
|
|
187
|
-
monthTokens += e.tokens
|
|
188
|
-
days30[Math.min(DAYS - 1, Math.floor((e.ts - start30) / DAY))] += e.tokens
|
|
189
|
-
if (e.ts >= startOfDay) tokensToday += e.tokens
|
|
190
|
-
if (now - e.ts < 5 * 3600000) tokens5h += e.tokens
|
|
191
|
-
if (now - e.ts < WEEK) {
|
|
192
|
-
tokensWeek += e.tokens
|
|
193
|
-
const m = e.model || 'unknown'
|
|
194
|
-
byModel.set(m, (byModel.get(m) || 0) + e.tokens)
|
|
195
|
-
if (e.cwd) {
|
|
196
|
-
const p = path.basename(e.cwd)
|
|
197
|
-
byProject.set(p, (byProject.get(p) || 0) + e.tokens)
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
// forget files that aged out of the window
|
|
203
|
-
for (const f of fileCache.keys()) if (!seen.has(f)) fileCache.delete(f)
|
|
204
|
-
|
|
205
|
-
const projects = ranked(byProject)
|
|
206
|
-
const byProjectArr = projects.slice(0, TOP_PROJECTS)
|
|
207
|
-
const rest = projects.slice(TOP_PROJECTS)
|
|
208
|
-
if (rest.length) {
|
|
209
|
-
byProjectArr.push({
|
|
210
|
-
label: `other · ${rest.length}`,
|
|
211
|
-
tokens: rest.reduce((n, p) => n + p.tokens, 0),
|
|
212
|
-
})
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const weeklyOnly = !!limits?.weekly && !limits.session
|
|
216
|
-
return {
|
|
217
|
-
active: !!latest && now - latest.mtime < ACTIVE_MS,
|
|
218
|
-
lastSeen: latest ? latest.mtime : null,
|
|
219
|
-
plan: limits?.plan || null,
|
|
220
|
-
// with no 5-hour window, the week is the budget that binds: it takes the
|
|
221
|
-
// session's place, the way Cursor's month does
|
|
222
|
-
session: limits ? windowNow(weeklyOnly ? limits.weekly : limits.session, now) : null,
|
|
223
|
-
weekly: limits && !weeklyOnly ? windowNow(limits.weekly, now) : null,
|
|
224
|
-
weeklyOnly,
|
|
225
|
-
limitsAt: limits?.ts || null,
|
|
226
|
-
tokensToday,
|
|
227
|
-
tokens5h,
|
|
228
|
-
tokensWeek,
|
|
229
|
-
byModel: ranked(byModel),
|
|
230
|
-
byProject: byProjectArr,
|
|
231
|
-
days30,
|
|
232
|
-
monthTokens,
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
module.exports = { getCodexUsage, detectCodex, setCodexDir }
|
package/config.json
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"_note": "Defaults bundled with the app. User settings are saved to ~/.claude-usage-monitor/config.json. Session/weekly % come from your account (log in via Settings).",
|
|
3
|
-
"mode": "floating",
|
|
4
|
-
"alerts": true,
|
|
5
|
-
"alertThresholds": [80, 95],
|
|
6
|
-
"fireThreshold": 90,
|
|
7
|
-
"zoom": 100,
|
|
8
|
-
"pollIntervalMs": 4000,
|
|
9
|
-
"activeThresholdMs": 20000,
|
|
10
|
-
"sleepThresholdMs": 300000
|
|
11
|
-
}
|
package/cursor.js
DELETED
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
const fs = require('node:fs')
|
|
2
|
-
const path = require('node:path')
|
|
3
|
-
const os = require('node:os')
|
|
4
|
-
|
|
5
|
-
// Cursor keeps no usage in local files. Its app stores a session token in
|
|
6
|
-
// `state.vscdb` (SQLite), and the same dashboard endpoints cursor.com uses
|
|
7
|
-
// answer with it — unofficial, so every field is read defensively. Activity
|
|
8
|
-
// comes from the agent transcripts under ~/.cursor/projects, which log each
|
|
9
|
-
// tool call much like Claude Code's own logs.
|
|
10
|
-
const API = 'https://api2.cursor.sh/aiserver.v1.DashboardService/'
|
|
11
|
-
const ACTIVE_MS = 60000 // a transcript that says nothing counts only while it's this fresh
|
|
12
|
-
// an unfinished turn this long without a write was abandoned (Cursor closed mid-run)
|
|
13
|
-
const OPEN_TURN_MS = 30 * 60000
|
|
14
|
-
const RECENT_TRANSCRIPTS = 8
|
|
15
|
-
const FETCH_MS = 5 * 60000 // a monthly budget moves slowly; don't hammer the API
|
|
16
|
-
const RETRY_MS = 60000
|
|
17
|
-
const TAIL_BYTES = 64 * 1024
|
|
18
|
-
|
|
19
|
-
function defaultStateDb() {
|
|
20
|
-
const tail = ['Cursor', 'User', 'globalStorage', 'state.vscdb']
|
|
21
|
-
if (process.platform === 'darwin')
|
|
22
|
-
return path.join(os.homedir(), 'Library', 'Application Support', ...tail)
|
|
23
|
-
if (process.platform === 'win32')
|
|
24
|
-
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), ...tail)
|
|
25
|
-
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), ...tail)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
let homeDir = path.join(os.homedir(), '.cursor')
|
|
29
|
-
let stateDb = defaultStateDb()
|
|
30
|
-
let fetchImpl = (...args) => globalThis.fetch(...args)
|
|
31
|
-
let limits = null // last good reading: { ts, plan, endMs, total, api }
|
|
32
|
-
let lastTry = -Infinity
|
|
33
|
-
let lastFailed = false
|
|
34
|
-
let signedOut = false
|
|
35
|
-
let inflight = null
|
|
36
|
-
|
|
37
|
-
// tests point these elsewhere; a change of place forgets the old reading
|
|
38
|
-
function configureCursor(opts = {}) {
|
|
39
|
-
if ('fetch' in opts) fetchImpl = opts.fetch || ((...args) => globalThis.fetch(...args))
|
|
40
|
-
if (!('home' in opts) && !('stateDb' in opts)) return
|
|
41
|
-
if ('home' in opts) homeDir = opts.home || path.join(os.homedir(), '.cursor')
|
|
42
|
-
if ('stateDb' in opts) stateDb = opts.stateDb || defaultStateDb()
|
|
43
|
-
limits = null
|
|
44
|
-
lastTry = -Infinity
|
|
45
|
-
lastFailed = false
|
|
46
|
-
signedOut = false
|
|
47
|
-
inflight = null
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const text = (v) => (v == null ? null : typeof v === 'string' ? v : Buffer.from(v).toString('utf8'))
|
|
51
|
-
|
|
52
|
-
// Electron's Node has `node:sqlite`; Bun, which runs the tests, has its own
|
|
53
|
-
// with the same prepare/get/close
|
|
54
|
-
function openReadOnly(file) {
|
|
55
|
-
try {
|
|
56
|
-
const { DatabaseSync } = require('node:sqlite')
|
|
57
|
-
return new DatabaseSync(file, { readOnly: true })
|
|
58
|
-
} catch (err) {
|
|
59
|
-
if (!process.versions.bun) throw err
|
|
60
|
-
const { Database } = require('bun:sqlite')
|
|
61
|
-
return new Database(file, { readonly: true })
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// the app's own session: its token, plus the plan it last cached
|
|
66
|
-
function readAuth() {
|
|
67
|
-
if (!fs.existsSync(stateDb)) return null
|
|
68
|
-
let db
|
|
69
|
-
try {
|
|
70
|
-
db = openReadOnly(stateDb)
|
|
71
|
-
const q = db.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
|
72
|
-
const get = (k) => text(q.get(k)?.value)
|
|
73
|
-
return { token: get('cursorAuth/accessToken'), plan: get('cursorAuth/stripeMembershipType') }
|
|
74
|
-
} catch {
|
|
75
|
-
return null
|
|
76
|
-
} finally {
|
|
77
|
-
db?.close()
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Settings → Connect: only a signed-in app has something to read
|
|
82
|
-
function detectCursor() {
|
|
83
|
-
const auth = readAuth()
|
|
84
|
-
return { found: !!auth?.token, plan: auth?.plan || null }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function post(method, token) {
|
|
88
|
-
const res = await fetchImpl(API + method, {
|
|
89
|
-
method: 'POST',
|
|
90
|
-
headers: {
|
|
91
|
-
Authorization: `Bearer ${token}`,
|
|
92
|
-
'Content-Type': 'application/json',
|
|
93
|
-
'Connect-Protocol-Version': '1',
|
|
94
|
-
},
|
|
95
|
-
body: '{}',
|
|
96
|
-
signal: AbortSignal.timeout(15000),
|
|
97
|
-
})
|
|
98
|
-
if (!res.ok) throw new Error(`cursor ${method}: HTTP ${res.status}`)
|
|
99
|
-
return res.json()
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const pctOf = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
|
|
103
|
-
|
|
104
|
-
// One fetch at a time. The token is read fresh each time: the Cursor app
|
|
105
|
-
// refreshes it on its own, so there is nothing to renew here.
|
|
106
|
-
function refreshLimits(now = Date.now()) {
|
|
107
|
-
if (inflight) return inflight
|
|
108
|
-
lastTry = now
|
|
109
|
-
inflight = (async () => {
|
|
110
|
-
const auth = readAuth()
|
|
111
|
-
signedOut = !auth?.token
|
|
112
|
-
if (signedOut) return
|
|
113
|
-
const [usage, info] = await Promise.all([
|
|
114
|
-
post('GetCurrentPeriodUsage', auth.token),
|
|
115
|
-
post('GetPlanInfo', auth.token).catch(() => null),
|
|
116
|
-
])
|
|
117
|
-
const pu = usage?.planUsage || {}
|
|
118
|
-
limits = {
|
|
119
|
-
ts: now,
|
|
120
|
-
plan: info?.planInfo?.planName?.toLowerCase() || auth.plan || null,
|
|
121
|
-
endMs: Number(usage?.billingCycleEnd) || null,
|
|
122
|
-
total: pctOf(pu.totalPercentUsed),
|
|
123
|
-
api: pctOf(pu.apiPercentUsed),
|
|
124
|
-
}
|
|
125
|
-
})()
|
|
126
|
-
.then(() => {
|
|
127
|
-
lastFailed = false
|
|
128
|
-
})
|
|
129
|
-
.catch((err) => {
|
|
130
|
-
lastFailed = true
|
|
131
|
-
console.error('cursor:', err.message)
|
|
132
|
-
})
|
|
133
|
-
.finally(() => {
|
|
134
|
-
inflight = null
|
|
135
|
-
})
|
|
136
|
-
return inflight
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// the billing cycle as a window; past its end, the % belongs to the old cycle
|
|
140
|
-
function cycleNow(pct, endMs, now) {
|
|
141
|
-
const expired = endMs != null && endMs <= now
|
|
142
|
-
return {
|
|
143
|
-
pct: expired ? null : pct,
|
|
144
|
-
resetMs: endMs != null && !expired ? endMs - now : null,
|
|
145
|
-
expired,
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// ---- activity ---------------------------------------------------------------
|
|
150
|
-
const ACTIVITY = {
|
|
151
|
-
Read: 'reading',
|
|
152
|
-
ReadFile: 'reading',
|
|
153
|
-
Grep: 'reading',
|
|
154
|
-
Glob: 'reading',
|
|
155
|
-
LS: 'reading',
|
|
156
|
-
ListDir: 'reading',
|
|
157
|
-
SemanticSearch: 'reading',
|
|
158
|
-
ReadLints: 'reading',
|
|
159
|
-
Write: 'editing',
|
|
160
|
-
StrReplace: 'editing',
|
|
161
|
-
Edit: 'editing',
|
|
162
|
-
MultiEdit: 'editing',
|
|
163
|
-
ApplyPatch: 'editing',
|
|
164
|
-
Delete: 'editing',
|
|
165
|
-
EditNotebook: 'editing',
|
|
166
|
-
Shell: 'running',
|
|
167
|
-
WebSearch: 'researching',
|
|
168
|
-
WebFetch: 'researching',
|
|
169
|
-
Task: 'delegating',
|
|
170
|
-
TodoWrite: 'planning',
|
|
171
|
-
CreatePlan: 'planning',
|
|
172
|
-
AskQuestion: 'waiting',
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// what the newest transcript lines say: a finished turn means nothing is
|
|
176
|
-
// running (null), an open one names its scene, and undefined means no telling
|
|
177
|
-
function activityOf(lines) {
|
|
178
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
179
|
-
let o
|
|
180
|
-
try {
|
|
181
|
-
o = JSON.parse(lines[i])
|
|
182
|
-
} catch {
|
|
183
|
-
continue
|
|
184
|
-
}
|
|
185
|
-
if (o.type === 'turn_ended') return null
|
|
186
|
-
if (o.role === 'assistant') {
|
|
187
|
-
const blocks = Array.isArray(o.message?.content) ? o.message.content : []
|
|
188
|
-
const tool = blocks.findLast((b) => b?.type === 'tool_use')
|
|
189
|
-
return (tool && ACTIVITY[tool.name]) || 'working'
|
|
190
|
-
}
|
|
191
|
-
if (o.role === 'user') return 'working' // just asked: the agent is thinking
|
|
192
|
-
}
|
|
193
|
-
return undefined
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const dirs = (dir) => {
|
|
197
|
-
try {
|
|
198
|
-
return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => !d.name.startsWith('.'))
|
|
199
|
-
} catch {
|
|
200
|
-
return []
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// every transcript, subagents included: projects/<p>/agent-transcripts/<id>/…
|
|
205
|
-
function transcripts() {
|
|
206
|
-
const out = []
|
|
207
|
-
for (const p of dirs(path.join(homeDir, 'projects'))) {
|
|
208
|
-
if (!p.isDirectory()) continue
|
|
209
|
-
const root = path.join(homeDir, 'projects', p.name, 'agent-transcripts')
|
|
210
|
-
for (const id of dirs(root)) {
|
|
211
|
-
if (!id.isDirectory()) continue
|
|
212
|
-
const dir = path.join(root, id.name)
|
|
213
|
-
for (const f of dirs(dir)) {
|
|
214
|
-
if (f.isFile() && f.name.endsWith('.jsonl')) out.push(path.join(dir, f.name))
|
|
215
|
-
else if (f.isDirectory() && f.name === 'subagents') {
|
|
216
|
-
for (const s of dirs(path.join(dir, f.name)))
|
|
217
|
-
if (s.isFile() && s.name.endsWith('.jsonl')) out.push(path.join(dir, f.name, s.name))
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return out
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function tailLines(file, size) {
|
|
226
|
-
const len = Math.min(size, TAIL_BYTES)
|
|
227
|
-
const buf = Buffer.alloc(len)
|
|
228
|
-
const fd = fs.openSync(file, 'r')
|
|
229
|
-
try {
|
|
230
|
-
fs.readSync(fd, buf, 0, len, size - len)
|
|
231
|
-
} finally {
|
|
232
|
-
fs.closeSync(fd)
|
|
233
|
-
}
|
|
234
|
-
const lines = buf.toString('utf8').split('\n')
|
|
235
|
-
if (len < size) lines.shift() // cut mid-line
|
|
236
|
-
return lines.filter(Boolean)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// A turn stays open while a long command runs or the model thinks, without a
|
|
240
|
-
// write, so an open turn in any recent transcript is Cursor at work. A
|
|
241
|
-
// transcript with nothing to say counts only while it's fresh.
|
|
242
|
-
function readActivity(now) {
|
|
243
|
-
const recent = []
|
|
244
|
-
let lastSeen = null
|
|
245
|
-
for (const file of transcripts()) {
|
|
246
|
-
try {
|
|
247
|
-
const st = fs.statSync(file)
|
|
248
|
-
if (lastSeen == null || st.mtimeMs > lastSeen) lastSeen = st.mtimeMs
|
|
249
|
-
if (now - st.mtimeMs < OPEN_TURN_MS) recent.push({ file, st })
|
|
250
|
-
} catch {}
|
|
251
|
-
}
|
|
252
|
-
recent.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs)
|
|
253
|
-
for (const { file, st } of recent.slice(0, RECENT_TRANSCRIPTS)) {
|
|
254
|
-
let said
|
|
255
|
-
try {
|
|
256
|
-
said = activityOf(tailLines(file, st.size))
|
|
257
|
-
} catch {}
|
|
258
|
-
const activity = said === undefined ? (now - st.mtimeMs < ACTIVE_MS ? 'working' : null) : said
|
|
259
|
-
if (activity) return { active: true, activity, lastSeen }
|
|
260
|
-
}
|
|
261
|
-
return { active: false, activity: null, lastSeen }
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// The poll's view of Cursor: activity from disk every tick, limits from the
|
|
265
|
-
// last fetch (a new one starts in the background when it's due).
|
|
266
|
-
function getCursorUsage(now = Date.now()) {
|
|
267
|
-
if (now - lastTry >= (limits && !lastFailed ? FETCH_MS : RETRY_MS)) refreshLimits(now)
|
|
268
|
-
return {
|
|
269
|
-
...readActivity(now),
|
|
270
|
-
plan: limits?.plan ?? null,
|
|
271
|
-
session: limits ? cycleNow(limits.total, limits.endMs, now) : null,
|
|
272
|
-
api: limits ? cycleNow(limits.api, limits.endMs, now) : null,
|
|
273
|
-
limitsAt: limits?.ts ?? null,
|
|
274
|
-
signedOut,
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
module.exports = { getCursorUsage, refreshLimits, detectCursor, configureCursor, activityOf }
|