@usagefleet/cli 1.2.162 → 1.2.164

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
@@ -78,7 +78,8 @@ shell once. `uninstall` removes both again, and self-update keeps them current.
78
78
  Desktop agent-mode (Cowork) sessions, and the
79
79
  [pi](https://github.com/badlogic/pi-mono) agent (`~/.pi/agent/sessions`,
80
80
  `anthropic` and `claude-bridge` provider records only; other providers don't
81
- touch Claude limits).
81
+ touch Claude limits), and the jcode agent (`~/.jcode/sessions`) and its pj fork
82
+ (`~/.pj/sessions`), Claude subscription turns only.
82
83
  A per-file byte offset in the config file means each line is sent once;
83
84
  rotation and truncation are handled and partial lines are never sent. Delivery
84
85
  is at-least-once — the server dedups on `uuid`.
@@ -157,6 +158,8 @@ it (`usagefleet config` prints this same table):
157
158
  | `projectsDir` | `USAGEFLEET_PROJECTS` | override `~/.claude/projects` |
158
159
  | `desktopDir` | `USAGEFLEET_DESKTOP` | override the Claude Desktop sessions dir; `off` to skip it |
159
160
  | `piDir` | `USAGEFLEET_PI` | pi sessions dirs — string or array in the file, comma-separated in env; `off` to skip |
161
+ | `jcodeDir` | `USAGEFLEET_JCODE` | override the jcode sessions dir (`~/.jcode/sessions`); `off` to skip it |
162
+ | `pjDir` | `USAGEFLEET_PJ` | override the pj sessions dir (`~/.pj/sessions`); `off` to skip it |
160
163
  | `interval` | `USAGEFLEET_INTERVAL` | watch poll seconds (default 15) |
161
164
  | `limitsInterval` | `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit reports (default 60; 300 on API keys, where each report costs a 1-token ping) |
162
165
  | `notifications` | `USAGEFLEET_NOTIFY` | desktop notifications, on by default (`false`/`0` disables) |
package/dist/collector.js CHANGED
@@ -5,6 +5,7 @@ import { detectClaudeAccount, tokenOwner } from './claude-account.js';
5
5
  import { detectClaudeCreds, macKeychainDenied } from './claude-creds.js';
6
6
  import { fetchLimits } from './claude-limits.js';
7
7
  import { ENDPOINT } from './config.js';
8
+ import { listJcodeSessions, readJcodeSession } from './jcode.js';
8
9
  import { maybeNotify } from './notifier.js';
9
10
  import { detectOs } from './os.js';
10
11
  import { RELEASE_VERSION } from './release.js';
@@ -21,9 +22,10 @@ const PROJECTS_SUBPATH = `${sep}.claude${sep}projects${sep}`;
21
22
  /** `status` shows the cached reading's age, so it stays within this of the last check. */
22
23
  const LIMITS_CACHE_REFRESH_MS = 10 * 60_000;
23
24
  /**
24
- * One full scan: tail every JSONL file from its stored offset, upload new usage
25
- * records (chunked to batchSize), and commit each file's offset only after all
26
- * its chunks are acknowledged (at-least-once; the server dedups on uuid).
25
+ * One full scan: tail every JSONL file from its stored offset and re-read every
26
+ * changed jcode session, upload new usage records (chunked to batchSize), and commit
27
+ * each source's progress only after all its chunks are acknowledged (at-least-once;
28
+ * the server dedups on uuid).
27
29
  */
28
30
  export async function runOnce(cfg, log = () => {
29
31
  /* empty */
@@ -50,12 +52,23 @@ export async function runOnce(cfg, log = () => {
50
52
  files.push({ fp, source: root.source });
51
53
  }
52
54
  }
55
+ const jcodeRoots = [
56
+ { dir: cfg.jcodeDir, source: 'jcode' },
57
+ { dir: cfg.pjDir, source: 'pj' },
58
+ ];
59
+ const jcodeSessions = jcodeRoots.flatMap(({ dir, source }) => dir ? listJcodeSessions(dir).map(fp => ({ fp, source })) : []);
60
+ // Each source yields its new records and how to commit having sent them, so both
61
+ // share one upload path: offsets for appended JSONL, a watermark for jcode and pj.
62
+ const reads = [
63
+ ...files.map(({ fp, source }) => ({ fp, read: () => tailPending(fp, source, state.files) })),
64
+ ...jcodeSessions.map(({ fp, source }) => ({ fp, read: () => jcodePending(fp, source, state.jcode) })),
65
+ ];
53
66
  const result = {
54
67
  accepted: 0,
55
68
  dropped: 0,
56
69
  duplicates: 0,
57
70
  failed: false,
58
- files: files.length,
71
+ files: reads.length,
59
72
  sent: 0,
60
73
  };
61
74
  // Defensive: a bad batchSize must never stall the chunk loop.
@@ -64,35 +77,35 @@ export async function runOnce(cfg, log = () => {
64
77
  // A server that is down or unreachable fails every file for the same reason,
65
78
  // so the cycle reports one count instead of a screenful of identical lines.
66
79
  let transientFiles = 0;
67
- for (const { fp, source } of files) {
68
- let tail;
80
+ for (const { fp, read } of reads) {
81
+ let pending;
69
82
  try {
70
- tail = tailFile(fp, state.files[fp], source);
83
+ pending = read();
71
84
  }
72
85
  catch (error) {
73
86
  // One unreadable/oversized file must not abort the whole cycle.
74
87
  log('warn', `skipped ${tilde(fp)} · ${error.message}`);
75
88
  continue;
76
89
  }
77
- if (!tail || tail.consumedBytes === 0) {
90
+ if (!pending) {
78
91
  continue;
79
92
  }
80
- if (tail.segments.length === 0) {
93
+ if (pending.segments.length === 0) {
81
94
  // Consumed only non-usage lines — safe to advance immediately.
82
- state.files[fp] = tail.nextState;
95
+ pending.commit();
83
96
  advanced = true;
84
97
  continue;
85
98
  }
86
99
  // sendChunk absorbs "invalid" by bisecting, so only auth/plan/transient escape.
87
100
  let outcome = 'ok';
88
- for (const chunk of chunks(tail.segments, step)) {
101
+ for (const chunk of chunks(pending.segments, step)) {
89
102
  outcome = await sendChunk(chunk.records, chunk.accountExtId, cfg, result, log);
90
103
  if (outcome !== 'ok') {
91
104
  break;
92
105
  }
93
106
  }
94
107
  if (outcome === 'ok') {
95
- state.files[fp] = tail.nextState;
108
+ pending.commit();
96
109
  advanced = true;
97
110
  }
98
111
  else if (outcome === 'auth') {
@@ -130,9 +143,12 @@ export async function runOnce(cfg, log = () => {
130
143
  // on every save, and a crash mid-cycle only costs a re-upload the server
131
144
  // dedups. Only our own section is replaced, so a token written by a
132
145
  // concurrent `usagefleet login` survives.
133
- if (pruneMissingFiles(state, files) || advanced) {
146
+ const pruned = pruneMissingFiles(state, files);
147
+ const prunedJcode = pruneGoneSessions(state.jcode, jcodeSessions.map(s => s.fp));
148
+ if (pruned || prunedJcode || advanced) {
134
149
  updateStore(cfg.storePath, store => {
135
150
  store.state.files = state.files;
151
+ store.state.jcode = state.jcode;
136
152
  store.state.updatedAt = new Date().toISOString();
137
153
  });
138
154
  }
@@ -200,6 +216,45 @@ function chunks(segments, step) {
200
216
  }
201
217
  return out;
202
218
  }
219
+ function tailPending(fp, source, files) {
220
+ const tail = tailFile(fp, files[fp], source);
221
+ if (!tail || tail.consumedBytes === 0) {
222
+ return null;
223
+ }
224
+ return {
225
+ commit: () => {
226
+ files[fp] = tail.nextState;
227
+ },
228
+ segments: tail.segments,
229
+ };
230
+ }
231
+ function jcodePending(fp, source, sessions) {
232
+ const session = readJcodeSession(fp, source, sessions[fp]);
233
+ if (!session) {
234
+ return null;
235
+ }
236
+ return {
237
+ commit: () => {
238
+ sessions[fp] = session.nextState;
239
+ },
240
+ // shortcut: jcode keeps its own Anthropic logins and stamps none on a session, so its
241
+ // turns book on the device's Claude Code account; read jcode's active login if they differ.
242
+ segments: session.records.length > 0 ? [{ accountExtId: undefined, records: session.records }] : [],
243
+ };
244
+ }
245
+ /** Drop the progress of jcode sessions no longer on disk; returns whether anything went. */
246
+ function pruneGoneSessions(sessions, listed) {
247
+ const kept = new Set(listed);
248
+ let removed = false;
249
+ for (const fp of Object.keys(sessions)) {
250
+ if (!kept.has(fp)) {
251
+ // oxlint-disable-next-line typescript/no-dynamic-delete -- state.jcode is a JSON blob keyed by path
252
+ delete sessions[fp];
253
+ removed = true;
254
+ }
255
+ }
256
+ return removed;
257
+ }
203
258
  /**
204
259
  * Drop offsets for logs that no longer exist on disk, so a long-lived install
205
260
  * does not grow its state file by one entry per Claude session forever. Only
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { defaultDesktopSessionsDir, defaultPiSessionsDirs, defaultProjectsDir } from './paths.js';
1
+ import { defaultDesktopSessionsDir, defaultJcodeSessionsDir, defaultPiSessionsDirs, defaultPjSessionsDir, defaultProjectsDir, } from './paths.js';
2
2
  import { readStore, storePath } from './store.js';
3
3
  /** Matches the server's BatchSchema `.max(1000)`. */
4
4
  const MAX_BATCH = 1000;
@@ -26,7 +26,9 @@ export function loadConfig() {
26
26
  accountTokens: file.accountTokens ?? {},
27
27
  batchSize,
28
28
  desktopDir: resolveOptionalDir(process.env.USAGEFLEET_DESKTOP, file.desktopDir, defaultDesktopSessionsDir()),
29
+ jcodeDir: resolveOptionalDir(process.env.USAGEFLEET_JCODE, file.jcodeDir, defaultJcodeSessionsDir()),
29
30
  piDirs: resolvePiDirs(process.env.USAGEFLEET_PI, file.piDir),
31
+ pjDir: resolveOptionalDir(process.env.USAGEFLEET_PJ, file.pjDir, defaultPjSessionsDir()),
30
32
  projectsDir: process.env.USAGEFLEET_PROJECTS || file.projectsDir || defaultProjectsDir(),
31
33
  storePath: storePath(),
32
34
  token,
@@ -71,7 +73,7 @@ export function resolvePiDirs(env, fromFile) {
71
73
  }
72
74
  return [...new Set(raw.map(d => d.trim()).filter(d => d.length > 0))];
73
75
  }
74
- /** Optional scan root (USAGEFLEET_DESKTOP): env "off"/"0" disables, env or
76
+ /** Optional scan root (USAGEFLEET_DESKTOP, USAGEFLEET_JCODE): env "off"/"0" disables, env or
75
77
  * config-file path overrides, else the auto-detected default. */
76
78
  function resolveOptionalDir(env, fromFile, fallback) {
77
79
  if (env === '0' || env?.toLowerCase() === 'off') {
package/dist/index.js CHANGED
@@ -89,7 +89,7 @@ async function cmdWatch() {
89
89
  const rawUpdate = positiveNumber(process.env.USAGEFLEET_UPDATE_INTERVAL, settings.updateInterval) ?? 6 * 60 * 60;
90
90
  const updateInterval = Math.max(60, rawUpdate) * 1000;
91
91
  let lastUpdateAt = 0;
92
- const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
92
+ const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs, cfg.jcodeDir, cfg.pjDir].filter((d) => !!d);
93
93
  console.log(header(`watching every ${interval / 1000}s`));
94
94
  console.log(hint(`${watching.map(tilde).join(' · ')} → ${host(ENDPOINT)}`));
95
95
  console.log('');
@@ -192,7 +192,7 @@ async function cmdStatus() {
192
192
  console.log('');
193
193
  console.log(row('endpoint', host(ENDPOINT)));
194
194
  console.log(row('device', `${state.deviceId} · token ${tokenFor(cfg, detectClaudeAccount()?.extId).slice(0, 8)}…`));
195
- const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
195
+ const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs, cfg.jcodeDir, cfg.pjDir].filter((d) => !!d);
196
196
  for (const [i, dir] of watching.entries()) {
197
197
  console.log(row(i === 0 ? 'watching' : '', tilde(dir)));
198
198
  }
@@ -282,6 +282,8 @@ function cmdConfig() {
282
282
  ['projectsDir', 'USAGEFLEET_PROJECTS', 'override ~/.claude/projects'],
283
283
  ['desktopDir', 'USAGEFLEET_DESKTOP', 'override the Claude Desktop dir ("off" disables)'],
284
284
  ['piDir', 'USAGEFLEET_PI', 'pi session dirs · string or array; env comma-separated'],
285
+ ['jcodeDir', 'USAGEFLEET_JCODE', 'override ~/.jcode/sessions ("off" disables)'],
286
+ ['pjDir', 'USAGEFLEET_PJ', 'override ~/.pj/sessions ("off" disables)'],
285
287
  ['interval', 'USAGEFLEET_INTERVAL', 'watch interval seconds (default 15)'],
286
288
  [
287
289
  'limitsInterval',
package/dist/jcode.js ADDED
@@ -0,0 +1,104 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { str } from './parser.js';
4
+ // "claude" and "claude-oauth" run on the Claude subscription; "claude-api" is a billed API key.
5
+ const SUBSCRIPTION_PROVIDERS = new Set(['claude', 'claude-oauth']);
6
+ // Same reason as the scanner's QUIET_MS: a write right after a read can keep the read's mtime.
7
+ const QUIET_MS = 10_000;
8
+ export function listJcodeSessions(dir) {
9
+ if (!existsSync(dir)) {
10
+ return [];
11
+ }
12
+ return readdirSync(dir)
13
+ .filter(name => name.startsWith('session_') && name.endsWith('.json'))
14
+ .map(name => join(dir, name));
15
+ }
16
+ /**
17
+ * New subscription turns of one jcode session, or null when its files have not
18
+ * changed since `prev`. jcode keeps a session as `<id>.json`, rewritten whole, plus
19
+ * `<id>.journal.jsonl`, appended per turn and folded into the snapshot at checkpoints
20
+ * (an interrupted one leaves a turn in both). So both are read and turns key on their
21
+ * id, and anything at or before `prev.uploadedThrough` was already sent.
22
+ */
23
+ export function readJcodeSession(snapshotPath, source, prev) {
24
+ const journalPath = snapshotPath.replace(/\.json$/, '.journal.jsonl');
25
+ const mtimeMs = Math.max(statSync(snapshotPath).mtimeMs, existsSync(journalPath) ? statSync(journalPath).mtimeMs : 0);
26
+ if (prev?.mtimeMs === mtimeMs) {
27
+ return null;
28
+ }
29
+ const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
30
+ // shortcut: snapshot turns take the session's current provider/model; a provider switch rewrites
31
+ // the snapshot, so a first backfill of such a session books older turns on the new provider.
32
+ const turns = (snapshot.messages ?? []).map(m => [m, snapshot]);
33
+ for (const line of readJournal(journalPath)) {
34
+ for (const m of line.append_messages ?? []) {
35
+ turns.push([m, line.meta ?? snapshot]);
36
+ }
37
+ }
38
+ const uploadedThrough = prev?.uploadedThrough ?? 0;
39
+ let newest = uploadedThrough;
40
+ const seen = new Set();
41
+ const records = [];
42
+ for (const [message, meta] of turns) {
43
+ const record = toRecord(message, meta, str(snapshot.id), source);
44
+ if (!record || seen.has(record.uuid) || Date.parse(record.timestamp) <= uploadedThrough) {
45
+ continue;
46
+ }
47
+ seen.add(record.uuid);
48
+ records.push(record);
49
+ newest = Math.max(newest, Date.parse(record.timestamp));
50
+ }
51
+ return {
52
+ nextState: { mtimeMs: Date.now() - mtimeMs > QUIET_MS ? mtimeMs : 0, uploadedThrough: newest },
53
+ records,
54
+ };
55
+ }
56
+ function readJournal(path) {
57
+ if (!existsSync(path)) {
58
+ return [];
59
+ }
60
+ const lines = [];
61
+ for (const text of readFileSync(path, 'utf-8').split('\n')) {
62
+ try {
63
+ lines.push(JSON.parse(text));
64
+ }
65
+ catch {
66
+ // The empty tail after the last newline, or the line jcode is still writing.
67
+ }
68
+ }
69
+ return lines;
70
+ }
71
+ /** jcode's `token_usage` is Anthropic's usage block (`prompt_tokens` is their sum, left out). */
72
+ function toRecord(m, meta, sessionId, source) {
73
+ const u = m.token_usage;
74
+ const id = str(m.id);
75
+ const timestamp = str(m.timestamp);
76
+ if (m.role !== 'assistant' ||
77
+ !u ||
78
+ !id ||
79
+ !timestamp ||
80
+ Number.isNaN(Date.parse(timestamp)) ||
81
+ !SUBSCRIPTION_PROVIDERS.has(str(meta.provider_key) ?? '')) {
82
+ return null;
83
+ }
84
+ return {
85
+ // jcode does not log the cache-write TTL; the server prices it by the user's setting.
86
+ cacheCreation1h: null,
87
+ cacheCreation5m: null,
88
+ cacheCreationTokens: u.cache_creation_input_tokens ?? 0,
89
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
90
+ cwd: str(meta.working_dir),
91
+ gitBranch: null,
92
+ inputTokens: u.input_tokens ?? 0,
93
+ messageId: id,
94
+ model: str(meta.model),
95
+ outputTokens: u.output_tokens ?? 0,
96
+ requestId: null,
97
+ serviceTier: null,
98
+ sessionId,
99
+ source,
100
+ timestamp,
101
+ uuid: `${source}:${id}`,
102
+ version: null,
103
+ };
104
+ }
package/dist/parser.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Only accept genuine strings; ignores numbers/objects that would later break. */
2
- function str(v) {
2
+ export function str(v) {
3
3
  return typeof v === 'string' && v.length > 0 ? v : null;
4
4
  }
5
5
  /** A valid ISO-ish timestamp string, else the current time (never an invalid one). */
package/dist/paths.js CHANGED
@@ -63,6 +63,15 @@ function realDir(dir) {
63
63
  return dir;
64
64
  }
65
65
  }
66
+ /** jcode keeps every session flat in `~/.jcode/sessions`. JCODE_HOME moves it, but a
67
+ * service never inherits the user's shell env, so a moved one is set via USAGEFLEET_JCODE. */
68
+ export function defaultJcodeSessionsDir() {
69
+ return join(homedir(), '.jcode', 'sessions');
70
+ }
71
+ /** pj is a jcode fork that keeps its sessions in `~/.pj/sessions`; JCODE_HOME moves it too. */
72
+ export function defaultPjSessionsDir() {
73
+ return join(homedir(), '.pj', 'sessions');
74
+ }
66
75
  /** pi's agent dir, the root for its sessions and extensions.
67
76
  * PI_CODING_AGENT_DIR is pi's own relocation knob; unlike the service-side
68
77
  * scan above, the guard installer runs from the user's shell where that env
package/dist/release.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by .github/workflows/release.yml.
2
- export const RELEASE_VERSION = "1.2.162";
2
+ export const RELEASE_VERSION = "1.2.164";
package/dist/store.js CHANGED
@@ -52,6 +52,7 @@ function normalize(raw) {
52
52
  desktopDir: raw.desktopDir,
53
53
  hook: raw.hook,
54
54
  interval: raw.interval,
55
+ jcodeDir: raw.jcodeDir,
55
56
  limits: raw.limits,
56
57
  limitsInterval: raw.limitsInterval,
57
58
  notifications: raw.notifications,
@@ -61,10 +62,12 @@ function normalize(raw) {
61
62
  },
62
63
  notifyThresholds: raw.notifyThresholds,
63
64
  piDir: raw.piDir,
65
+ pjDir: raw.pjDir,
64
66
  projectsDir: raw.projectsDir,
65
67
  state: {
66
68
  deviceId: raw.state?.deviceId || randomUUID(),
67
69
  files: raw.state?.files ?? {},
70
+ jcode: raw.state?.jcode ?? {},
68
71
  updatedAt: raw.state?.updatedAt ?? new Date().toISOString(),
69
72
  },
70
73
  token: raw.token,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagefleet/cli",
3
- "version": "1.2.162",
3
+ "version": "1.2.164",
4
4
  "description": "Tails Claude Code, Claude Desktop, and pi agent JSONL logs and reports token usage to a UsageFleet server.",
5
5
  "keywords": [
6
6
  "claude",