@usagefleet/cli 1.2.161 → 1.2.163
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 +10 -1
- package/dist/collector.js +64 -13
- package/dist/config.js +3 -2
- package/dist/index.js +21 -7
- package/dist/jcode.js +104 -0
- package/dist/parser.js +1 -1
- package/dist/paths.js +5 -0
- package/dist/release.js +1 -1
- package/dist/service.js +13 -3
- package/dist/store.js +2 -0
- package/package.json +1 -1
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`, Claude
|
|
82
|
+
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,7 @@ 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 |
|
|
160
162
|
| `interval` | `USAGEFLEET_INTERVAL` | watch poll seconds (default 15) |
|
|
161
163
|
| `limitsInterval` | `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit reports (default 60; 300 on API keys, where each report costs a 1-token ping) |
|
|
162
164
|
| `notifications` | `USAGEFLEET_NOTIFY` | desktop notifications, on by default (`false`/`0` disables) |
|
|
@@ -211,6 +213,9 @@ until you re-run `usagefleet login` under the new one.
|
|
|
211
213
|
it holds your token.
|
|
212
214
|
- **Linux** — a `systemd --user` unit, plus `loginctl enable-linger $USER` so it
|
|
213
215
|
survives logout. If `systemctl` can't be driven it prints the manual steps.
|
|
216
|
+
- **Linux without systemd** (a Docker container, WSL without it) — no service.
|
|
217
|
+
Run `usagefleet watch` as the container's command or under your own
|
|
218
|
+
supervisor, with a restart policy such as `restart: unless-stopped`.
|
|
214
219
|
- **Windows** — a Scheduled Task (`usagefleet`) starting at logon, restarting on
|
|
215
220
|
failure, running hidden through a `wscript` launcher in
|
|
216
221
|
`%LOCALAPPDATA%\usagefleet` (which carries the env values set at install
|
|
@@ -231,6 +236,10 @@ restart the service on it. `usagefleet update` does the same on demand. npm is
|
|
|
231
236
|
called through the absolute path next to the running `node`, because a
|
|
232
237
|
launchd/systemd service gets a minimal PATH.
|
|
233
238
|
|
|
239
|
+
A `watch` that is not the installed service (a container's command, a
|
|
240
|
+
terminal) exits with code 75 after an update, so whatever runs it starts the
|
|
241
|
+
new version.
|
|
242
|
+
|
|
234
243
|
Every failure is a no-op — registry unreachable, non-semver version, npm
|
|
235
244
|
missing or exiting non-zero, local `dev` builds — and the service is only
|
|
236
245
|
restarted after npm reports success.
|
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
|
|
25
|
-
* records (chunked to batchSize), and commit
|
|
26
|
-
* its chunks are acknowledged (at-least-once;
|
|
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,19 @@ export async function runOnce(cfg, log = () => {
|
|
|
50
52
|
files.push({ fp, source: root.source });
|
|
51
53
|
}
|
|
52
54
|
}
|
|
55
|
+
const jcodeSessions = cfg.jcodeDir ? listJcodeSessions(cfg.jcodeDir) : [];
|
|
56
|
+
// Each source yields its new records and how to commit having sent them, so both
|
|
57
|
+
// share one upload path: offsets for appended JSONL, a watermark for jcode.
|
|
58
|
+
const reads = [
|
|
59
|
+
...files.map(({ fp, source }) => ({ fp, read: () => tailPending(fp, source, state.files) })),
|
|
60
|
+
...jcodeSessions.map(fp => ({ fp, read: () => jcodePending(fp, state.jcode) })),
|
|
61
|
+
];
|
|
53
62
|
const result = {
|
|
54
63
|
accepted: 0,
|
|
55
64
|
dropped: 0,
|
|
56
65
|
duplicates: 0,
|
|
57
66
|
failed: false,
|
|
58
|
-
files:
|
|
67
|
+
files: reads.length,
|
|
59
68
|
sent: 0,
|
|
60
69
|
};
|
|
61
70
|
// Defensive: a bad batchSize must never stall the chunk loop.
|
|
@@ -64,35 +73,35 @@ export async function runOnce(cfg, log = () => {
|
|
|
64
73
|
// A server that is down or unreachable fails every file for the same reason,
|
|
65
74
|
// so the cycle reports one count instead of a screenful of identical lines.
|
|
66
75
|
let transientFiles = 0;
|
|
67
|
-
for (const { fp,
|
|
68
|
-
let
|
|
76
|
+
for (const { fp, read } of reads) {
|
|
77
|
+
let pending;
|
|
69
78
|
try {
|
|
70
|
-
|
|
79
|
+
pending = read();
|
|
71
80
|
}
|
|
72
81
|
catch (error) {
|
|
73
82
|
// One unreadable/oversized file must not abort the whole cycle.
|
|
74
83
|
log('warn', `skipped ${tilde(fp)} · ${error.message}`);
|
|
75
84
|
continue;
|
|
76
85
|
}
|
|
77
|
-
if (!
|
|
86
|
+
if (!pending) {
|
|
78
87
|
continue;
|
|
79
88
|
}
|
|
80
|
-
if (
|
|
89
|
+
if (pending.segments.length === 0) {
|
|
81
90
|
// Consumed only non-usage lines — safe to advance immediately.
|
|
82
|
-
|
|
91
|
+
pending.commit();
|
|
83
92
|
advanced = true;
|
|
84
93
|
continue;
|
|
85
94
|
}
|
|
86
95
|
// sendChunk absorbs "invalid" by bisecting, so only auth/plan/transient escape.
|
|
87
96
|
let outcome = 'ok';
|
|
88
|
-
for (const chunk of chunks(
|
|
97
|
+
for (const chunk of chunks(pending.segments, step)) {
|
|
89
98
|
outcome = await sendChunk(chunk.records, chunk.accountExtId, cfg, result, log);
|
|
90
99
|
if (outcome !== 'ok') {
|
|
91
100
|
break;
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
103
|
if (outcome === 'ok') {
|
|
95
|
-
|
|
104
|
+
pending.commit();
|
|
96
105
|
advanced = true;
|
|
97
106
|
}
|
|
98
107
|
else if (outcome === 'auth') {
|
|
@@ -130,9 +139,12 @@ export async function runOnce(cfg, log = () => {
|
|
|
130
139
|
// on every save, and a crash mid-cycle only costs a re-upload the server
|
|
131
140
|
// dedups. Only our own section is replaced, so a token written by a
|
|
132
141
|
// concurrent `usagefleet login` survives.
|
|
133
|
-
|
|
142
|
+
const pruned = pruneMissingFiles(state, files);
|
|
143
|
+
const prunedJcode = pruneGoneSessions(state.jcode, jcodeSessions);
|
|
144
|
+
if (pruned || prunedJcode || advanced) {
|
|
134
145
|
updateStore(cfg.storePath, store => {
|
|
135
146
|
store.state.files = state.files;
|
|
147
|
+
store.state.jcode = state.jcode;
|
|
136
148
|
store.state.updatedAt = new Date().toISOString();
|
|
137
149
|
});
|
|
138
150
|
}
|
|
@@ -200,6 +212,45 @@ function chunks(segments, step) {
|
|
|
200
212
|
}
|
|
201
213
|
return out;
|
|
202
214
|
}
|
|
215
|
+
function tailPending(fp, source, files) {
|
|
216
|
+
const tail = tailFile(fp, files[fp], source);
|
|
217
|
+
if (!tail || tail.consumedBytes === 0) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
commit: () => {
|
|
222
|
+
files[fp] = tail.nextState;
|
|
223
|
+
},
|
|
224
|
+
segments: tail.segments,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function jcodePending(fp, sessions) {
|
|
228
|
+
const session = readJcodeSession(fp, sessions[fp]);
|
|
229
|
+
if (!session) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
commit: () => {
|
|
234
|
+
sessions[fp] = session.nextState;
|
|
235
|
+
},
|
|
236
|
+
// shortcut: jcode keeps its own Anthropic logins and stamps none on a session, so its
|
|
237
|
+
// turns book on the device's Claude Code account; read jcode's active login if they differ.
|
|
238
|
+
segments: session.records.length > 0 ? [{ accountExtId: undefined, records: session.records }] : [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Drop the progress of jcode sessions no longer on disk; returns whether anything went. */
|
|
242
|
+
function pruneGoneSessions(sessions, listed) {
|
|
243
|
+
const kept = new Set(listed);
|
|
244
|
+
let removed = false;
|
|
245
|
+
for (const fp of Object.keys(sessions)) {
|
|
246
|
+
if (!kept.has(fp)) {
|
|
247
|
+
// oxlint-disable-next-line typescript/no-dynamic-delete -- state.jcode is a JSON blob keyed by path
|
|
248
|
+
delete sessions[fp];
|
|
249
|
+
removed = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return removed;
|
|
253
|
+
}
|
|
203
254
|
/**
|
|
204
255
|
* Drop offsets for logs that no longer exist on disk, so a long-lived install
|
|
205
256
|
* 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, 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,6 +26,7 @@ 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),
|
|
30
31
|
projectsDir: process.env.USAGEFLEET_PROJECTS || file.projectsDir || defaultProjectsDir(),
|
|
31
32
|
storePath: storePath(),
|
|
@@ -71,7 +72,7 @@ export function resolvePiDirs(env, fromFile) {
|
|
|
71
72
|
}
|
|
72
73
|
return [...new Set(raw.map(d => d.trim()).filter(d => d.length > 0))];
|
|
73
74
|
}
|
|
74
|
-
/** Optional scan root (USAGEFLEET_DESKTOP): env "off"/"0" disables, env or
|
|
75
|
+
/** Optional scan root (USAGEFLEET_DESKTOP, USAGEFLEET_JCODE): env "off"/"0" disables, env or
|
|
75
76
|
* config-file path overrides, else the auto-detected default. */
|
|
76
77
|
function resolveOptionalDir(env, fromFile, fallback) {
|
|
77
78
|
if (env === '0' || env?.toLowerCase() === 'off') {
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { loadNotifyConfig } from './notifier.js';
|
|
|
9
9
|
import { sendNotification } from './notify.js';
|
|
10
10
|
import { detectOs } from './os.js';
|
|
11
11
|
import { RELEASE_VERSION } from './release.js';
|
|
12
|
-
import { serviceStatus } from './service.js';
|
|
12
|
+
import { isServiceProcess, serviceStatus } from './service.js';
|
|
13
13
|
import { readStore, storePath, updateStore } from './store.js';
|
|
14
14
|
import { ago, bar, blue, dim, fail, green, header, hint, host, line, note, pct, row, state as stateLine, step, tilde, warn, yellow, } from './ui.js';
|
|
15
15
|
import { checkForUpdate } from './update.js';
|
|
@@ -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].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('');
|
|
@@ -112,7 +112,13 @@ async function cmdWatch() {
|
|
|
112
112
|
const nowMs = Date.now();
|
|
113
113
|
if (nowMs - lastUpdateAt >= updateInterval) {
|
|
114
114
|
lastUpdateAt = nowMs;
|
|
115
|
-
await checkForUpdate((level, m) => line(level === 'ok' ? blue('↻') : yellow('!'), m));
|
|
115
|
+
const updated = await checkForUpdate((level, m) => line(level === 'ok' ? blue('↻') : yellow('!'), m));
|
|
116
|
+
// `login` restarts only the installed service. Any other watch (a container's command, a terminal)
|
|
117
|
+
// exits so its supervisor starts the new version; non-zero because `restart: on-failure` ignores 0.
|
|
118
|
+
if (updated && !isServiceProcess()) {
|
|
119
|
+
line(blue('↻'), `exiting to restart on ${updated}`);
|
|
120
|
+
process.exit(75);
|
|
121
|
+
}
|
|
116
122
|
}
|
|
117
123
|
if (nowMs - lastLimitsAt >= limitsInterval) {
|
|
118
124
|
lastLimitsAt = nowMs;
|
|
@@ -176,9 +182,7 @@ async function cmdStatus() {
|
|
|
176
182
|
const creds = await detectClaudeCreds();
|
|
177
183
|
console.log(header());
|
|
178
184
|
console.log('');
|
|
179
|
-
console.log(svc
|
|
180
|
-
? stateLine('ok', 'service', `running${svc.pid ? dim(` · pid ${svc.pid}`) : ''}`)
|
|
181
|
-
: stateLine('bad', 'service', `${svc.state} ${dim(svc.state === 'stopped' ? '· check the log' : '· run `usagefleet login`')}`));
|
|
185
|
+
console.log(serviceLine(svc));
|
|
182
186
|
console.log(creds
|
|
183
187
|
? stateLine('ok', 'claude', `${creds.source}${dim(creds.subscriptionType ? ` · ${creds.subscriptionType}` : '')}`)
|
|
184
188
|
: stateLine('warn', 'claude', `no login ${dim('· sign in with `claude` or set ANTHROPIC_API_KEY')}`));
|
|
@@ -188,13 +192,22 @@ async function cmdStatus() {
|
|
|
188
192
|
console.log('');
|
|
189
193
|
console.log(row('endpoint', host(ENDPOINT)));
|
|
190
194
|
console.log(row('device', `${state.deviceId} · token ${tokenFor(cfg, detectClaudeAccount()?.extId).slice(0, 8)}…`));
|
|
191
|
-
const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
|
|
195
|
+
const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs, cfg.jcodeDir].filter((d) => !!d);
|
|
192
196
|
for (const [i, dir] of watching.entries()) {
|
|
193
197
|
console.log(row(i === 0 ? 'watching' : '', tilde(dir)));
|
|
194
198
|
}
|
|
195
199
|
console.log(row('tracked', `${tracked} file${tracked === 1 ? '' : 's'} · ${mb} MB read · synced ${ago(state.updatedAt)}`));
|
|
196
200
|
console.log(row('config', tilde(cfg.storePath)));
|
|
197
201
|
}
|
|
202
|
+
function serviceLine(svc) {
|
|
203
|
+
if (svc.state === 'running') {
|
|
204
|
+
return stateLine('ok', 'service', `running${svc.pid ? dim(` · pid ${svc.pid}`) : ''}`);
|
|
205
|
+
}
|
|
206
|
+
if (svc.state === 'no autostart') {
|
|
207
|
+
return stateLine('warn', 'service', `no autostart here ${dim('· keep `usagefleet watch` running yourself')}`);
|
|
208
|
+
}
|
|
209
|
+
return stateLine('bad', 'service', `${svc.state} ${dim(svc.state === 'stopped' ? '· check the log' : '· run `usagefleet login`')}`);
|
|
210
|
+
}
|
|
198
211
|
/** Worst of the two windows decides the dot colour. */
|
|
199
212
|
function limitHealth(fiveHour, sevenDay) {
|
|
200
213
|
const worst = Math.max(fiveHour ?? 0, sevenDay ?? 0);
|
|
@@ -269,6 +282,7 @@ function cmdConfig() {
|
|
|
269
282
|
['projectsDir', 'USAGEFLEET_PROJECTS', 'override ~/.claude/projects'],
|
|
270
283
|
['desktopDir', 'USAGEFLEET_DESKTOP', 'override the Claude Desktop dir ("off" disables)'],
|
|
271
284
|
['piDir', 'USAGEFLEET_PI', 'pi session dirs · string or array; env comma-separated'],
|
|
285
|
+
['jcodeDir', 'USAGEFLEET_JCODE', 'override ~/.jcode/sessions ("off" disables)'],
|
|
272
286
|
['interval', 'USAGEFLEET_INTERVAL', 'watch interval seconds (default 15)'],
|
|
273
287
|
[
|
|
274
288
|
'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, 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));
|
|
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) {
|
|
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: 'jcode',
|
|
100
|
+
timestamp,
|
|
101
|
+
uuid: `jcode:${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,11 @@ 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
|
+
}
|
|
66
71
|
/** pi's agent dir, the root for its sessions and extensions.
|
|
67
72
|
* PI_CODING_AGENT_DIR is pi's own relocation knob; unlike the service-side
|
|
68
73
|
* 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.
|
|
2
|
+
export const RELEASE_VERSION = "1.2.163";
|
package/dist/service.js
CHANGED
|
@@ -129,6 +129,10 @@ function macPlistPath() {
|
|
|
129
129
|
function systemdUnitPath() {
|
|
130
130
|
return join(homedir(), '.config', 'systemd', 'user', 'usagefleet.service');
|
|
131
131
|
}
|
|
132
|
+
/** sd_booted(3): without systemd as init (a Docker container, WSL without it) a unit would never start. */
|
|
133
|
+
function systemdBooted() {
|
|
134
|
+
return existsSync('/run/systemd/system');
|
|
135
|
+
}
|
|
132
136
|
/** Env vars that are actually set, for baking into the unit so the service
|
|
133
137
|
* behaves like the install shell. Derived from the USAGEFLEET_ prefix rather
|
|
134
138
|
* than a hand-kept allowlist: that list had already drifted, silently dropping
|
|
@@ -373,7 +377,7 @@ ${envXml}
|
|
|
373
377
|
console.log(row('logs', tilde(macLogDir())));
|
|
374
378
|
return collectingNow();
|
|
375
379
|
}
|
|
376
|
-
if (process.platform === 'linux') {
|
|
380
|
+
if (process.platform === 'linux' && systemdBooted()) {
|
|
377
381
|
// systemd: quote values, escape backslash/quote, reject newlines.
|
|
378
382
|
const envLines = env
|
|
379
383
|
.filter(([, v]) => !/[\r\n]/.test(v))
|
|
@@ -505,7 +509,7 @@ export function serviceStatus() {
|
|
|
505
509
|
const pid = out?.match(/\bpid = (\d+)/)?.[1];
|
|
506
510
|
return pid ? { pid: Number(pid), state: 'running' } : { state: 'stopped' };
|
|
507
511
|
}
|
|
508
|
-
if (process.platform === 'linux') {
|
|
512
|
+
if (process.platform === 'linux' && systemdBooted()) {
|
|
509
513
|
if (!existsSync(systemdUnitPath())) {
|
|
510
514
|
return { state: 'not installed' };
|
|
511
515
|
}
|
|
@@ -523,7 +527,13 @@ export function serviceStatus() {
|
|
|
523
527
|
}
|
|
524
528
|
return { state: /Status:\s*Running/i.test(out) ? 'running' : 'stopped' };
|
|
525
529
|
}
|
|
526
|
-
return { state: '
|
|
530
|
+
return { state: 'no autostart' };
|
|
531
|
+
}
|
|
532
|
+
/** Whether this process is the one the installed service runs, i.e. the one `login` restarts.
|
|
533
|
+
* Windows reports no pid, so a running task counts as this process, as it did before. */
|
|
534
|
+
export function isServiceProcess() {
|
|
535
|
+
const svc = serviceStatus();
|
|
536
|
+
return svc.state === 'running' && (svc.pid === undefined || svc.pid === process.pid);
|
|
527
537
|
}
|
|
528
538
|
/** Best-effort removal of the ~60 MB binary copy that pre-npm releases parked in
|
|
529
539
|
* the app-support dir (plus the `.old` file a Windows in-place update left).
|
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,
|
|
@@ -65,6 +66,7 @@ function normalize(raw) {
|
|
|
65
66
|
state: {
|
|
66
67
|
deviceId: raw.state?.deviceId || randomUUID(),
|
|
67
68
|
files: raw.state?.files ?? {},
|
|
69
|
+
jcode: raw.state?.jcode ?? {},
|
|
68
70
|
updatedAt: raw.state?.updatedAt ?? new Date().toISOString(),
|
|
69
71
|
},
|
|
70
72
|
token: raw.token,
|