@adecore/agents 0.39.0 → 0.40.1
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/dist/async.d.ts +5 -0
- package/dist/async.js +13 -5
- package/dist/chat/acp-protocol.js +3 -5
- package/dist/chat/chat-core.js +0 -2
- package/dist/chat/chat-process.d.ts +1 -0
- package/dist/chat/chat-process.js +1 -1
- package/dist/chat/chat-session.js +6 -8
- package/dist/chat/claude-protocol.js +1 -6
- package/dist/chat/claude-transcript.js +1 -3
- package/dist/chat/codex-protocol.js +1 -6
- package/dist/chat/projector.js +1 -3
- package/dist/chat/request-summary.js +1 -3
- package/dist/coded-error.js +1 -5
- package/dist/context/argv.js +1 -2
- package/dist/context/verb.js +1 -3
- package/dist/error-text.d.ts +1 -0
- package/dist/error-text.js +4 -0
- package/dist/fields.d.ts +3 -0
- package/dist/fields.js +12 -0
- package/dist/fs.d.ts +1 -0
- package/dist/fs.js +8 -4
- package/dist/lineage.js +11 -5
- package/dist/messages/notice-store.js +3 -4
- package/dist/providers/accounts/acp-account.js +3 -20
- package/dist/providers/accounts/service.d.ts +1 -2
- package/dist/providers/accounts/service.js +5 -9
- package/dist/providers/accounts/status.js +1 -3
- package/dist/providers/acp-agent-state.js +1 -3
- package/dist/providers/claude.js +0 -1
- package/dist/providers/codex-models.json +3 -2
- package/dist/providers/codex.js +0 -1
- package/dist/providers/detect.js +1 -1
- package/dist/providers/shell-commands.js +1 -2
- package/dist/run-process.js +1 -3
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +5 -8
- package/dist/tasks/background-limit.js +3 -2
- package/dist/tasks/task-coordinator.js +11 -17
- package/dist/testing/watch.js +0 -1
- package/dist/usage/limits/monitor.js +1 -2
- package/dist/usage/prices-snapshot.json +8 -0
- package/dist/usage/readers/claude.js +3 -10
- package/dist/usage/readers/codex.js +2 -9
- package/dist/usage/readers/grok.js +3 -10
- package/dist/usage/readers/host-log.js +3 -10
- package/dist/usage/record.d.ts +2 -0
- package/dist/usage/record.js +13 -0
- package/package.json +6 -6
- package/src/async.ts +23 -5
- package/src/chat/acp-backend.ts +121 -0
- package/src/chat/acp-protocol.ts +172 -6
- package/src/chat/attachment-store.ts +21 -0
- package/src/chat/backend.ts +0 -2
- package/src/chat/bookmark-store.ts +23 -0
- package/src/chat/chat-core.ts +208 -7
- package/src/chat/chat-log.ts +19 -0
- package/src/chat/chat-process.ts +52 -1
- package/src/chat/chat-session.ts +401 -10
- package/src/chat/chat-store.ts +50 -0
- package/src/chat/chat-title.ts +9 -0
- package/src/chat/claude-backend.ts +50 -0
- package/src/chat/claude-protocol.ts +155 -8
- package/src/chat/claude-title.ts +10 -0
- package/src/chat/claude-transcript.ts +47 -4
- package/src/chat/codex-backend.ts +131 -0
- package/src/chat/codex-protocol.ts +180 -8
- package/src/chat/codex-thread.ts +23 -0
- package/src/chat/composer-preferences.ts +6 -0
- package/src/chat/context-breakdown.ts +11 -0
- package/src/chat/delta-coalescer.ts +8 -0
- package/src/chat/external-sessions.ts +28 -0
- package/src/chat/generated-image.ts +25 -0
- package/src/chat/index.ts +5 -0
- package/src/chat/input.ts +13 -0
- package/src/chat/json-rpc-transport.ts +28 -0
- package/src/chat/limit-resume.ts +13 -0
- package/src/chat/projector.ts +160 -4
- package/src/chat/readable-text.ts +3 -0
- package/src/chat/request-summary.ts +18 -4
- package/src/chat/subagent-projection.ts +3 -0
- package/src/chat/subagent-reader.ts +76 -0
- package/src/chat/subagent-settlement.ts +24 -0
- package/src/chat/thread-search.ts +13 -0
- package/src/chat/thread.ts +31 -0
- package/src/chat/ui-queries.ts +122 -0
- package/src/chat/ui-replies.ts +34 -0
- package/src/chat/visual-store.ts +49 -0
- package/src/chat/wake-chat.ts +3 -0
- package/src/client-sinks.ts +2 -0
- package/src/coded-error.ts +1 -5
- package/src/context/argv.ts +18 -2
- package/src/context/index.ts +1 -0
- package/src/context/refusal.ts +3 -0
- package/src/context/verb.ts +32 -4
- package/src/coordination/index.ts +10 -0
- package/src/error-text.ts +7 -0
- package/src/fields.ts +14 -0
- package/src/fs.ts +20 -4
- package/src/host/agent-host.ts +20 -0
- package/src/host/environment.ts +6 -0
- package/src/host/handlers.ts +18 -0
- package/src/host/memory-port.ts +4 -0
- package/src/host/wiring.ts +10 -0
- package/src/index.ts +1 -0
- package/src/lineage.ts +24 -5
- package/src/messages/deliver-message.ts +6 -0
- package/src/messages/deliver-notice.ts +13 -0
- package/src/messages/notice-store.ts +16 -4
- package/src/modes.ts +1 -0
- package/src/outbox/outbox-worker.ts +31 -0
- package/src/outbox/outbox.ts +5 -0
- package/src/providers/accounts/accounts.ts +36 -0
- package/src/providers/accounts/acp-account.ts +22 -27
- package/src/providers/accounts/launch.ts +4 -0
- package/src/providers/accounts/service.ts +110 -12
- package/src/providers/accounts/shadow-home.ts +10 -0
- package/src/providers/accounts/status.ts +13 -4
- package/src/providers/accounts/store.ts +25 -0
- package/src/providers/accounts/variables.ts +11 -1
- package/src/providers/acp-agent-state.ts +24 -3
- package/src/providers/acp-provider.ts +5 -0
- package/src/providers/acp-usage-log.ts +2 -0
- package/src/providers/catalog.ts +17 -0
- package/src/providers/claude.ts +10 -1
- package/src/providers/codex-models.json +3 -2
- package/src/providers/codex-rules.ts +3 -0
- package/src/providers/codex.ts +2 -1
- package/src/providers/detect.ts +6 -1
- package/src/providers/provider.ts +1 -1
- package/src/providers/registry.ts +18 -2
- package/src/providers/shell-commands.ts +37 -2
- package/src/record-directory.ts +19 -0
- package/src/run-process.ts +24 -3
- package/src/serializer.ts +3 -0
- package/src/skills.ts +73 -10
- package/src/tasks/background-limit.ts +9 -2
- package/src/tasks/end-children.ts +9 -0
- package/src/tasks/give-task.ts +8 -0
- package/src/tasks/task-coordinator.ts +81 -17
- package/src/tasks/task-store.ts +28 -0
- package/src/tasks/waiting-child.ts +21 -0
- package/src/tasks/wake-parent.ts +26 -0
- package/src/tasks/wiring.ts +22 -0
- package/src/testing/accounts.ts +2 -0
- package/src/testing/fake-acp-capture.ts +16 -0
- package/src/testing/fake-acp.ts +66 -0
- package/src/testing/fake-claude.ts +177 -0
- package/src/testing/fake-cli.ts +29 -0
- package/src/testing/fake-codex.ts +122 -0
- package/src/testing/manual-clock.ts +2 -0
- package/src/testing/watch.ts +7 -1
- package/src/title-file.ts +12 -0
- package/src/usage/accounts.ts +6 -0
- package/src/usage/aggregate.ts +19 -0
- package/src/usage/exchange.ts +12 -0
- package/src/usage/index-file.ts +27 -0
- package/src/usage/limits/monitor.ts +31 -3
- package/src/usage/limits/normalize.ts +30 -0
- package/src/usage/limits/probe.ts +17 -0
- package/src/usage/prices-snapshot.json +8 -0
- package/src/usage/pricing.ts +35 -0
- package/src/usage/projects.ts +8 -0
- package/src/usage/readers/claude.ts +12 -9
- package/src/usage/readers/codex.ts +40 -8
- package/src/usage/readers/grok.ts +17 -9
- package/src/usage/readers/host-log.ts +9 -9
- package/src/usage/record.ts +22 -0
- package/src/usage/roots.ts +6 -0
- package/src/usage/scanner.ts +61 -0
- package/src/usage/usage-service.ts +14 -0
- package/src/watch-seam.ts +8 -0
- package/src/windows-command.ts +12 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { UsageProvider } from '@adecore/agent-contracts';
|
|
2
|
-
import {
|
|
2
|
+
import { asString, countsTokens, int, parseObject, type UsageRecord } from '../record.ts';
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
* One call to an API a host makes itself, a line of the usage log it keeps. Such an API has no CLI and so
|
|
@@ -32,21 +32,19 @@ export function hostLogMightCarryUsage(line: string): boolean {
|
|
|
32
32
|
|
|
33
33
|
/* The provider is the root's, so one reader serves every API a host logs. */
|
|
34
34
|
export function parseHostUsageLine(line: string, provider: UsageProvider): UsageRecord | null {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
parsed = JSON.parse(line);
|
|
38
|
-
} catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
const entry = asObject(parsed);
|
|
35
|
+
const entry = parseObject(line);
|
|
36
|
+
|
|
42
37
|
if (entry === null) {
|
|
43
38
|
return null;
|
|
44
39
|
}
|
|
40
|
+
|
|
45
41
|
const model = asString(entry.model);
|
|
46
42
|
const timestampMs = typeof entry.at === 'number' && Number.isFinite(entry.at) ? entry.at : Number.NaN;
|
|
43
|
+
|
|
47
44
|
if (model === '' || Number.isNaN(timestampMs)) {
|
|
48
45
|
return null;
|
|
49
46
|
}
|
|
47
|
+
|
|
50
48
|
const totals = {
|
|
51
49
|
calls: 1,
|
|
52
50
|
input: int(entry.input),
|
|
@@ -56,9 +54,11 @@ export function parseHostUsageLine(line: string, provider: UsageProvider): Usage
|
|
|
56
54
|
output: int(entry.output),
|
|
57
55
|
reasoning: int(entry.reasoning)
|
|
58
56
|
};
|
|
59
|
-
|
|
57
|
+
|
|
58
|
+
if (!countsTokens(totals)) {
|
|
60
59
|
return null;
|
|
61
60
|
}
|
|
61
|
+
|
|
62
62
|
return {
|
|
63
63
|
provider,
|
|
64
64
|
timestampMs,
|
package/src/usage/record.ts
CHANGED
|
@@ -28,6 +28,20 @@ export function asObject(value: unknown): Record<string, unknown> | null {
|
|
|
28
28
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/* A line or file of a transcript as a JSON object, or null for anything else. */
|
|
32
|
+
export function parseObject(text: string): Record<string, unknown> | null {
|
|
33
|
+
try {
|
|
34
|
+
return asObject(JSON.parse(text));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Whether a call moved any tokens at all; one that moved none is not a call worth a record. */
|
|
41
|
+
export function countsTokens(totals: UsageTotals): boolean {
|
|
42
|
+
return totals.input + totals.cacheRead + totals.cacheWrite + totals.output !== 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
export function asString(value: unknown): string {
|
|
32
46
|
return typeof value === 'string' ? value : '';
|
|
33
47
|
}
|
|
@@ -46,22 +60,30 @@ function maxTotals(into: UsageTotals, from: UsageTotals): void {
|
|
|
46
60
|
export function foldByKey(records: readonly UsageRecord[]): UsageRecord[] {
|
|
47
61
|
const folded: UsageRecord[] = [];
|
|
48
62
|
const at = new Map<string, number>();
|
|
63
|
+
|
|
49
64
|
for (const record of records) {
|
|
50
65
|
if (record.dedupeKey === null) {
|
|
51
66
|
folded.push(record);
|
|
67
|
+
|
|
52
68
|
continue;
|
|
53
69
|
}
|
|
70
|
+
|
|
54
71
|
const seen = at.get(record.dedupeKey);
|
|
72
|
+
|
|
55
73
|
if (seen === undefined) {
|
|
56
74
|
at.set(record.dedupeKey, folded.length);
|
|
57
75
|
folded.push({ ...record, totals: { ...record.totals } });
|
|
76
|
+
|
|
58
77
|
continue;
|
|
59
78
|
}
|
|
79
|
+
|
|
60
80
|
const into = folded[seen]!;
|
|
61
81
|
maxTotals(into.totals, record.totals);
|
|
82
|
+
|
|
62
83
|
if (record.costUsd !== undefined) {
|
|
63
84
|
into.costUsd = Math.max(into.costUsd ?? 0, record.costUsd);
|
|
64
85
|
}
|
|
65
86
|
}
|
|
87
|
+
|
|
66
88
|
return folded;
|
|
67
89
|
}
|
package/src/usage/roots.ts
CHANGED
|
@@ -19,6 +19,7 @@ const TRANSCRIPTS: Partial<Record<UsageProvider, string>> & Record<'claude' | 'c
|
|
|
19
19
|
/* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
|
|
20
20
|
export function usageRoots(env: Record<string, string | undefined> = process.env): UsageRootPath[] {
|
|
21
21
|
const home = homeFolder(env);
|
|
22
|
+
|
|
22
23
|
return [
|
|
23
24
|
{ provider: 'claude', path: join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), TRANSCRIPTS.claude) },
|
|
24
25
|
{ provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) },
|
|
@@ -29,19 +30,24 @@ export function usageRoots(env: Record<string, string | undefined> = process.env
|
|
|
29
30
|
/* The transcripts of every account, once per folder: Codex accounts over one home share its sessions. */
|
|
30
31
|
export function accountRoots(folders: ReadonlyArray<{ id: string; kind: UsageProvider; folder: string }>): UsageRootPath[] {
|
|
31
32
|
const roots = new Map<string, UsageRootPath & { accounts: string[] }>();
|
|
33
|
+
|
|
32
34
|
for (const { id, kind, folder } of folders) {
|
|
33
35
|
const transcripts = TRANSCRIPTS[kind];
|
|
36
|
+
|
|
34
37
|
if (transcripts === undefined) {
|
|
35
38
|
continue;
|
|
36
39
|
}
|
|
40
|
+
|
|
37
41
|
const path = join(folder, transcripts);
|
|
38
42
|
const key = `${kind}\0${path}`;
|
|
39
43
|
const root = roots.get(key);
|
|
44
|
+
|
|
40
45
|
if (root === undefined) {
|
|
41
46
|
roots.set(key, { provider: kind, path, accounts: [id] });
|
|
42
47
|
} else {
|
|
43
48
|
root.accounts.push(id);
|
|
44
49
|
}
|
|
45
50
|
}
|
|
51
|
+
|
|
46
52
|
return [...roots.values()];
|
|
47
53
|
}
|
package/src/usage/scanner.ts
CHANGED
|
@@ -23,17 +23,22 @@ const NEWLINE = 0x0a;
|
|
|
23
23
|
/* Hashes the first `length` bytes of a file into `hash`, a chunk at a time. */
|
|
24
24
|
async function hashPrefix(path: string, length: number, hash: Hash): Promise<Hash> {
|
|
25
25
|
const handle = await open(path, 'r');
|
|
26
|
+
|
|
26
27
|
try {
|
|
27
28
|
const chunk = Buffer.alloc(Math.min(length, HASH_CHUNK_BYTES));
|
|
28
29
|
let offset = 0;
|
|
30
|
+
|
|
29
31
|
while (offset < length) {
|
|
30
32
|
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, length - offset), offset);
|
|
33
|
+
|
|
31
34
|
if (bytesRead === 0) {
|
|
32
35
|
break;
|
|
33
36
|
}
|
|
37
|
+
|
|
34
38
|
hash.update(chunk.subarray(0, bytesRead));
|
|
35
39
|
offset += bytesRead;
|
|
36
40
|
}
|
|
41
|
+
|
|
37
42
|
return hash;
|
|
38
43
|
} finally {
|
|
39
44
|
await handle.close();
|
|
@@ -54,10 +59,12 @@ export interface LineRead {
|
|
|
54
59
|
export async function forEachLine(chunks: AsyncIterable<Uint8Array>, hash: Hash, onLine: (line: string) => void): Promise<LineRead> {
|
|
55
60
|
let consumed = 0;
|
|
56
61
|
let pending: Buffer[] = [];
|
|
62
|
+
|
|
57
63
|
for await (const piece of chunks) {
|
|
58
64
|
const chunk = Buffer.from(piece.buffer, piece.byteOffset, piece.byteLength);
|
|
59
65
|
let start = 0;
|
|
60
66
|
let newline = chunk.indexOf(NEWLINE, start);
|
|
67
|
+
|
|
61
68
|
while (newline !== -1) {
|
|
62
69
|
const end = newline + 1;
|
|
63
70
|
const line = pending.length === 0 ? chunk.subarray(start, end) : Buffer.concat([...pending, chunk.subarray(start, end)]);
|
|
@@ -68,11 +75,13 @@ export async function forEachLine(chunks: AsyncIterable<Uint8Array>, hash: Hash,
|
|
|
68
75
|
start = end;
|
|
69
76
|
newline = chunk.indexOf(NEWLINE, start);
|
|
70
77
|
}
|
|
78
|
+
|
|
71
79
|
if (start < chunk.length) {
|
|
72
80
|
// A copy: the stream may hand the same memory out again for its next chunk.
|
|
73
81
|
pending.push(Buffer.from(chunk.subarray(start)));
|
|
74
82
|
}
|
|
75
83
|
}
|
|
84
|
+
|
|
76
85
|
return { consumed, rest: Buffer.concat(pending).toString('utf8') };
|
|
77
86
|
}
|
|
78
87
|
|
|
@@ -108,35 +117,43 @@ function isUsageFile(provider: UsageProvider, name: string): boolean {
|
|
|
108
117
|
|
|
109
118
|
async function listUsageFiles(dir: string, provider: UsageProvider, out: string[] = []): Promise<string[]> {
|
|
110
119
|
let entries;
|
|
120
|
+
|
|
111
121
|
try {
|
|
112
122
|
entries = await readdir(dir, { withFileTypes: true });
|
|
113
123
|
} catch {
|
|
114
124
|
// A directory that cannot be read is one the scan walks past; the root itself reports status.
|
|
115
125
|
return out;
|
|
116
126
|
}
|
|
127
|
+
|
|
117
128
|
for (const entry of entries) {
|
|
118
129
|
const path = join(dir, entry.name);
|
|
130
|
+
|
|
119
131
|
if (entry.isDirectory()) {
|
|
120
132
|
await listUsageFiles(path, provider, out);
|
|
121
133
|
} else if (entry.isFile() && isUsageFile(provider, entry.name)) {
|
|
122
134
|
out.push(path);
|
|
123
135
|
}
|
|
124
136
|
}
|
|
137
|
+
|
|
125
138
|
return out;
|
|
126
139
|
}
|
|
127
140
|
|
|
128
141
|
/* The bytes of a file from `from` to its end, a chunk at a time. */
|
|
129
142
|
async function* fileChunks(path: string, from: number): AsyncGenerator<Uint8Array> {
|
|
130
143
|
const handle = await open(path, 'r');
|
|
144
|
+
|
|
131
145
|
try {
|
|
132
146
|
let position = from;
|
|
147
|
+
|
|
133
148
|
for (;;) {
|
|
134
149
|
// A buffer per chunk: a decompressor may still hold the one before.
|
|
135
150
|
const chunk = Buffer.alloc(READ_CHUNK_BYTES);
|
|
136
151
|
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
|
|
152
|
+
|
|
137
153
|
if (bytesRead === 0) {
|
|
138
154
|
return;
|
|
139
155
|
}
|
|
156
|
+
|
|
140
157
|
position += bytesRead;
|
|
141
158
|
yield chunk.subarray(0, bytesRead);
|
|
142
159
|
}
|
|
@@ -150,24 +167,30 @@ function readBytes(path: string, from: number, compressed: boolean): AsyncIterab
|
|
|
150
167
|
if (!compressed) {
|
|
151
168
|
return fileChunks(path, from);
|
|
152
169
|
}
|
|
170
|
+
|
|
153
171
|
const source = Readable.from(fileChunks(path, from));
|
|
154
172
|
const decompressed = createZstdDecompress();
|
|
155
173
|
source.on('error', (error) => decompressed.destroy(error));
|
|
174
|
+
|
|
156
175
|
return source.pipe(decompressed);
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
/* The usage record of one transcript line, when it carries one. */
|
|
160
179
|
function parseLine(raw: string, provider: UsageProvider, codex: CodexParserState | null): UsageRecord | null {
|
|
161
180
|
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
181
|
+
|
|
162
182
|
if (line === '') {
|
|
163
183
|
return null;
|
|
164
184
|
}
|
|
185
|
+
|
|
165
186
|
if (provider === 'claude') {
|
|
166
187
|
return claudeMightCarryUsage(line) ? parseClaudeLine(line) : null;
|
|
167
188
|
}
|
|
189
|
+
|
|
168
190
|
if (provider === 'codex') {
|
|
169
191
|
return codex !== null && codexMightCarryUsage(line) ? parseCodexLine(line, codex) : null;
|
|
170
192
|
}
|
|
193
|
+
|
|
171
194
|
// Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
|
|
172
195
|
return hostLogMightCarryUsage(line) ? parseHostUsageLine(line, provider) : null;
|
|
173
196
|
}
|
|
@@ -186,14 +209,18 @@ async function parseLines(
|
|
|
186
209
|
): Promise<ParsedFile> {
|
|
187
210
|
const codex = provider === 'codex' ? (state ?? createCodexState()) : null;
|
|
188
211
|
const records: UsageRecord[] = [];
|
|
212
|
+
|
|
189
213
|
const read = await forEachLine(chunks, hash, (line) => {
|
|
190
214
|
const record = parseLine(line, provider, codex);
|
|
215
|
+
|
|
191
216
|
if (record !== null) {
|
|
192
217
|
records.push(record);
|
|
193
218
|
}
|
|
194
219
|
});
|
|
220
|
+
|
|
195
221
|
const committed = codex === null ? null : cloneCodexState(codex);
|
|
196
222
|
const tail = parseLine(read.rest, provider, codex);
|
|
223
|
+
|
|
197
224
|
return { records, tail: tail === null ? [] : [tail], offset: from + read.consumed, codex: committed };
|
|
198
225
|
}
|
|
199
226
|
|
|
@@ -211,14 +238,20 @@ type AccountOf = (record: UsageRecord) => string | undefined;
|
|
|
211
238
|
*/
|
|
212
239
|
export function accountResolver(root: UsageRootPath, sessionAccounts: () => ReadonlyMap<string, string>): AccountOf {
|
|
213
240
|
const accounts = root.accounts ?? [root.provider];
|
|
241
|
+
|
|
214
242
|
const stored = (id: string): string | undefined => (id === root.provider ? undefined : id);
|
|
243
|
+
|
|
215
244
|
if (accounts.length === 1) {
|
|
216
245
|
const only = stored(accounts[0]!);
|
|
246
|
+
|
|
217
247
|
return () => only;
|
|
218
248
|
}
|
|
249
|
+
|
|
219
250
|
const fallback = accounts.includes(root.provider) ? root.provider : accounts[0]!;
|
|
251
|
+
|
|
220
252
|
return (record) => {
|
|
221
253
|
const ran = sessionAccounts().get(`${root.provider}\0${record.sessionId}`);
|
|
254
|
+
|
|
222
255
|
return stored(ran !== undefined && accounts.includes(ran) ? ran : fallback);
|
|
223
256
|
};
|
|
224
257
|
}
|
|
@@ -226,6 +259,7 @@ export function accountResolver(root: UsageRootPath, sessionAccounts: () => Read
|
|
|
226
259
|
function withAccount(records: UsageRecord[], accountOf: AccountOf): UsageRecord[] {
|
|
227
260
|
return records.map((record) => {
|
|
228
261
|
const account = accountOf(record);
|
|
262
|
+
|
|
229
263
|
return account === undefined ? record : { ...record, account };
|
|
230
264
|
});
|
|
231
265
|
}
|
|
@@ -257,7 +291,9 @@ export class UsageScanner {
|
|
|
257
291
|
if (this.loaded) {
|
|
258
292
|
return;
|
|
259
293
|
}
|
|
294
|
+
|
|
260
295
|
this.loaded = true;
|
|
296
|
+
|
|
261
297
|
try {
|
|
262
298
|
this.index = decodeIndex(await readFile(this.file, 'utf8'));
|
|
263
299
|
} catch (e) {
|
|
@@ -274,37 +310,47 @@ export class UsageScanner {
|
|
|
274
310
|
let files = 0;
|
|
275
311
|
let changedFiles = 0;
|
|
276
312
|
let sessions: ReadonlyMap<string, string> | null = null;
|
|
313
|
+
|
|
277
314
|
const sessionAccounts = (): ReadonlyMap<string, string> => {
|
|
278
315
|
sessions ??= this.sessionAccounts();
|
|
316
|
+
|
|
279
317
|
return sessions;
|
|
280
318
|
};
|
|
281
319
|
|
|
282
320
|
for (const root of this.roots()) {
|
|
283
321
|
try {
|
|
284
322
|
const info = await stat(root.path);
|
|
323
|
+
|
|
285
324
|
if (!info.isDirectory()) {
|
|
286
325
|
roots.push({ provider: root.provider, path: root.path, status: 'missing', message: null });
|
|
326
|
+
|
|
287
327
|
continue;
|
|
288
328
|
}
|
|
289
329
|
} catch (e) {
|
|
290
330
|
const missing = isNotFound(e);
|
|
331
|
+
|
|
291
332
|
roots.push({
|
|
292
333
|
provider: root.provider,
|
|
293
334
|
path: root.path,
|
|
294
335
|
status: missing ? 'missing' : 'failed',
|
|
295
336
|
message: missing ? null : errorText(e)
|
|
296
337
|
});
|
|
338
|
+
|
|
297
339
|
continue;
|
|
298
340
|
}
|
|
341
|
+
|
|
299
342
|
let failure: string | null = null;
|
|
300
343
|
const accountOf = accountResolver(root, sessionAccounts);
|
|
301
344
|
const paths = await listUsageFiles(root.path, root.provider);
|
|
302
345
|
const plain = new Set(paths);
|
|
346
|
+
|
|
303
347
|
for (const path of paths) {
|
|
304
348
|
if (root.provider === 'codex' && path.endsWith('.jsonl.zst') && plain.has(path.slice(0, -4))) {
|
|
305
349
|
continue;
|
|
306
350
|
}
|
|
351
|
+
|
|
307
352
|
files += 1;
|
|
353
|
+
|
|
308
354
|
try {
|
|
309
355
|
if (await this.readFileInto(path, root.provider, accountOf)) {
|
|
310
356
|
changedFiles += 1;
|
|
@@ -314,6 +360,7 @@ export class UsageScanner {
|
|
|
314
360
|
failure ??= errorText(e);
|
|
315
361
|
}
|
|
316
362
|
}
|
|
363
|
+
|
|
317
364
|
roots.push({ provider: root.provider, path: root.path, status: failure === null ? 'ok' : 'failed', message: failure });
|
|
318
365
|
}
|
|
319
366
|
|
|
@@ -322,15 +369,18 @@ export class UsageScanner {
|
|
|
322
369
|
await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
|
|
323
370
|
await writeAtomic(this.file, encodeIndex(this.index));
|
|
324
371
|
}
|
|
372
|
+
|
|
325
373
|
return { at: Date.now(), files, changedFiles, durationMs: Date.now() - started, roots };
|
|
326
374
|
}
|
|
327
375
|
|
|
328
376
|
/* Every record the index holds, with the copies one message left in several transcripts folded. */
|
|
329
377
|
records(): UsageRecord[] {
|
|
330
378
|
const all: UsageRecord[] = [];
|
|
379
|
+
|
|
331
380
|
for (const file of this.index.values()) {
|
|
332
381
|
all.push(...file.records, ...file.tail);
|
|
333
382
|
}
|
|
383
|
+
|
|
334
384
|
return foldByKey(all);
|
|
335
385
|
}
|
|
336
386
|
|
|
@@ -340,6 +390,7 @@ export class UsageScanner {
|
|
|
340
390
|
const key = compressed ? path.slice(0, -4) : path;
|
|
341
391
|
const known = this.index.get(key);
|
|
342
392
|
const currentParser = provider !== 'codex' || known?.parserVersion === CODEX_PARSER_VERSION;
|
|
393
|
+
|
|
343
394
|
if (
|
|
344
395
|
currentParser &&
|
|
345
396
|
known !== undefined &&
|
|
@@ -350,9 +401,11 @@ export class UsageScanner {
|
|
|
350
401
|
) {
|
|
351
402
|
return false;
|
|
352
403
|
}
|
|
404
|
+
|
|
353
405
|
if (readsWhole(provider)) {
|
|
354
406
|
return this.readWholeInto(path, provider, accountOf, info);
|
|
355
407
|
}
|
|
408
|
+
|
|
356
409
|
const appended =
|
|
357
410
|
!compressed &&
|
|
358
411
|
currentParser &&
|
|
@@ -361,6 +414,7 @@ export class UsageScanner {
|
|
|
361
414
|
known.provider === provider &&
|
|
362
415
|
info.size > known.size &&
|
|
363
416
|
known.head?.startsWith('sha256:') === true;
|
|
417
|
+
|
|
364
418
|
// A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
|
|
365
419
|
const prefix = appended ? await hashPrefix(path, known.offset, createHash('sha256')) : null;
|
|
366
420
|
const grown = prefix !== null && known !== undefined && headOf(prefix) === known.head ? known : null;
|
|
@@ -368,6 +422,7 @@ export class UsageScanner {
|
|
|
368
422
|
const from = grown?.offset ?? 0;
|
|
369
423
|
const parsed = await parseLines(readBytes(path, from, compressed), hash, provider, from, grown?.codex ?? null);
|
|
370
424
|
const before = grown?.records ?? [];
|
|
425
|
+
|
|
371
426
|
const entry: IndexedFile = {
|
|
372
427
|
provider,
|
|
373
428
|
size: info.size,
|
|
@@ -380,8 +435,10 @@ export class UsageScanner {
|
|
|
380
435
|
head: headOf(hash),
|
|
381
436
|
...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
|
|
382
437
|
};
|
|
438
|
+
|
|
383
439
|
this.index.set(key, entry);
|
|
384
440
|
this.dirty = true;
|
|
441
|
+
|
|
385
442
|
return true;
|
|
386
443
|
}
|
|
387
444
|
|
|
@@ -392,9 +449,11 @@ export class UsageScanner {
|
|
|
392
449
|
*/
|
|
393
450
|
private async readWholeInto(path: string, provider: UsageProvider, accountOf: AccountOf, info: { size: number; mtimeMs: number }): Promise<boolean> {
|
|
394
451
|
const records = parseGrokUsage(await readFile(path, 'utf8'), path);
|
|
452
|
+
|
|
395
453
|
if (records === null) {
|
|
396
454
|
return false;
|
|
397
455
|
}
|
|
456
|
+
|
|
398
457
|
this.index.set(path, {
|
|
399
458
|
provider,
|
|
400
459
|
size: info.size,
|
|
@@ -405,7 +464,9 @@ export class UsageScanner {
|
|
|
405
464
|
codex: null,
|
|
406
465
|
head: null
|
|
407
466
|
});
|
|
467
|
+
|
|
408
468
|
this.dirty = true;
|
|
469
|
+
|
|
409
470
|
return true;
|
|
410
471
|
}
|
|
411
472
|
}
|
|
@@ -30,13 +30,16 @@ export interface UsageServiceOptions {
|
|
|
30
30
|
function withRecordAccounts(accounts: UsageAccount[], records: readonly UsageRecord[]): UsageAccount[] {
|
|
31
31
|
const listed = new Set(accounts.map((account) => account.id));
|
|
32
32
|
const gone: UsageAccount[] = [];
|
|
33
|
+
|
|
33
34
|
for (const record of records) {
|
|
34
35
|
const id = accountOfRecord(record);
|
|
36
|
+
|
|
35
37
|
if (!listed.has(id)) {
|
|
36
38
|
listed.add(id);
|
|
37
39
|
gone.push({ id, kind: record.provider, label: id });
|
|
38
40
|
}
|
|
39
41
|
}
|
|
42
|
+
|
|
40
43
|
return [...accounts, ...gone];
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -76,6 +79,7 @@ export class UsageService {
|
|
|
76
79
|
/* The page is open here: keep the numbers moving until it closes or the socket does. */
|
|
77
80
|
follow(clientId: string): void {
|
|
78
81
|
this.followers.add(clientId);
|
|
82
|
+
|
|
79
83
|
this.timer ??= setInterval(() => {
|
|
80
84
|
void this.rescan();
|
|
81
85
|
}, SCAN_TTL_MS);
|
|
@@ -83,6 +87,7 @@ export class UsageService {
|
|
|
83
87
|
|
|
84
88
|
unfollow(clientId: string): void {
|
|
85
89
|
this.followers.delete(clientId);
|
|
90
|
+
|
|
86
91
|
if (this.followers.size === 0 && this.timer !== null) {
|
|
87
92
|
clearInterval(this.timer);
|
|
88
93
|
this.timer = null;
|
|
@@ -93,8 +98,10 @@ export class UsageService {
|
|
|
93
98
|
if (Date.now() - this.report.at > SCAN_TTL_MS) {
|
|
94
99
|
await this.rescan();
|
|
95
100
|
}
|
|
101
|
+
|
|
96
102
|
const records = this.scanner.records();
|
|
97
103
|
const { buckets, models, projects, sessions } = await aggregate(records, payload, this.prices, await this.knownProjects());
|
|
104
|
+
|
|
98
105
|
return {
|
|
99
106
|
from: payload.from,
|
|
100
107
|
to: payload.to,
|
|
@@ -131,6 +138,7 @@ export class UsageService {
|
|
|
131
138
|
this.inFlight ??= this.runScan().finally(() => {
|
|
132
139
|
this.inFlight = null;
|
|
133
140
|
});
|
|
141
|
+
|
|
134
142
|
return this.inFlight;
|
|
135
143
|
}
|
|
136
144
|
|
|
@@ -141,20 +149,26 @@ export class UsageService {
|
|
|
141
149
|
this.sinks.emit({ event: 'usage.changed', payload: { scannedAt: this.report.at } });
|
|
142
150
|
}
|
|
143
151
|
});
|
|
152
|
+
|
|
144
153
|
const [report] = await Promise.all([
|
|
145
154
|
this.scanner.scan().catch((e: unknown) => {
|
|
146
155
|
console.error('The usage scan failed:', errorText(e));
|
|
156
|
+
|
|
147
157
|
return null;
|
|
148
158
|
}),
|
|
149
159
|
this.prices.load(),
|
|
150
160
|
this.rates.load()
|
|
151
161
|
]);
|
|
162
|
+
|
|
152
163
|
this.failed = report === null;
|
|
164
|
+
|
|
153
165
|
if (report === null) {
|
|
154
166
|
// The last good numbers stay on screen; only the moment of the scan is not moved on.
|
|
155
167
|
return;
|
|
156
168
|
}
|
|
169
|
+
|
|
157
170
|
this.report = report;
|
|
171
|
+
|
|
158
172
|
if (report.changedFiles > 0) {
|
|
159
173
|
this.sinks.emit({ event: 'usage.changed', payload: { scannedAt: report.at } });
|
|
160
174
|
}
|
package/src/watch-seam.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const SYSTEM_WATCH: WatchSeams = {
|
|
|
29
29
|
watch: (path, options, listener) => watch(path, options, listener),
|
|
30
30
|
schedule: (callback, ms) => {
|
|
31
31
|
const timer = setTimeout(callback, ms);
|
|
32
|
+
|
|
32
33
|
return () => clearTimeout(timer);
|
|
33
34
|
}
|
|
34
35
|
};
|
|
@@ -46,11 +47,14 @@ export interface Settled {
|
|
|
46
47
|
*/
|
|
47
48
|
export function settled(seams: WatchSeams, ms: number, run: () => unknown): Settled {
|
|
48
49
|
let cancel: (() => void) | null = null;
|
|
50
|
+
|
|
49
51
|
return {
|
|
50
52
|
nudge: () => {
|
|
51
53
|
cancel?.();
|
|
54
|
+
|
|
52
55
|
cancel = seams.schedule(() => {
|
|
53
56
|
cancel = null;
|
|
57
|
+
|
|
54
58
|
return run();
|
|
55
59
|
}, ms);
|
|
56
60
|
},
|
|
@@ -92,11 +96,14 @@ export class PerClientWatches<TState> {
|
|
|
92
96
|
remove(clientId: string, key: string): void {
|
|
93
97
|
const watches = this.byClient.get(clientId);
|
|
94
98
|
const state = watches?.get(key);
|
|
99
|
+
|
|
95
100
|
if (!watches || state === undefined) {
|
|
96
101
|
return;
|
|
97
102
|
}
|
|
103
|
+
|
|
98
104
|
this.stop(state);
|
|
99
105
|
watches.delete(key);
|
|
106
|
+
|
|
100
107
|
if (watches.size === 0) {
|
|
101
108
|
this.byClient.delete(clientId);
|
|
102
109
|
}
|
|
@@ -106,6 +113,7 @@ export class PerClientWatches<TState> {
|
|
|
106
113
|
for (const state of this.byClient.get(clientId)?.values() ?? []) {
|
|
107
114
|
this.stop(state);
|
|
108
115
|
}
|
|
116
|
+
|
|
109
117
|
this.byClient.delete(clientId);
|
|
110
118
|
}
|
|
111
119
|
}
|
package/src/windows-command.ts
CHANGED
|
@@ -25,14 +25,19 @@ const CMD_SPECIAL = /([()\][%!^"`<>&|;, *?])/g;
|
|
|
25
25
|
*/
|
|
26
26
|
export function spawnTarget(command: string, args: readonly string[], options: SpawnTargetOptions = {}): SpawnTarget {
|
|
27
27
|
const { env = process.env, platform = process.platform, isFile = existingFile } = options;
|
|
28
|
+
|
|
28
29
|
if (platform !== 'win32') {
|
|
29
30
|
return { file: command, args: [...args] };
|
|
30
31
|
}
|
|
32
|
+
|
|
31
33
|
const resolved = findOnPath(command, env, isFile) ?? command;
|
|
34
|
+
|
|
32
35
|
if (!/\.(cmd|bat)$/i.test(resolved)) {
|
|
33
36
|
return { file: resolved, args: [...args] };
|
|
34
37
|
}
|
|
38
|
+
|
|
35
39
|
const line = [escapeForCmd(resolved), ...args.map((arg) => escapeForCmd(escapeForCmd(quoteArgument(arg))))].join(' ');
|
|
40
|
+
|
|
36
41
|
return { file: variable(env, 'ComSpec') ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], windowsVerbatimArguments: true };
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -41,27 +46,33 @@ function findOnPath(command: string, env: Record<string, string | undefined>, is
|
|
|
41
46
|
.split(';')
|
|
42
47
|
.map((extension) => extension.trim().toLowerCase())
|
|
43
48
|
.filter((extension) => extension !== '');
|
|
49
|
+
|
|
44
50
|
const hasExtension = extensions.includes(win32.extname(command).toLowerCase());
|
|
51
|
+
|
|
45
52
|
const folders = /[\\/]/.test(command)
|
|
46
53
|
? ['']
|
|
47
54
|
: (variable(env, 'PATH') ?? '')
|
|
48
55
|
.split(';')
|
|
49
56
|
.map((folder) => folder.trim().replace(/^"(.*)"$/, '$1'))
|
|
50
57
|
.filter((folder) => folder !== '');
|
|
58
|
+
|
|
51
59
|
for (const folder of folders) {
|
|
52
60
|
const base = folder === '' ? command : win32.join(folder, command);
|
|
53
61
|
const candidates = hasExtension ? [base] : extensions.map((extension) => `${base}${extension}`);
|
|
54
62
|
const found = candidates.find((candidate) => isFile(candidate));
|
|
63
|
+
|
|
55
64
|
if (found !== undefined) {
|
|
56
65
|
return found;
|
|
57
66
|
}
|
|
58
67
|
}
|
|
68
|
+
|
|
59
69
|
return null;
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
// Windows treats variable names case-insensitively, and an environment often carries `Path`.
|
|
63
73
|
function variable(env: Record<string, string | undefined>, name: string): string | undefined {
|
|
64
74
|
const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name.toUpperCase());
|
|
75
|
+
|
|
65
76
|
return key === undefined ? undefined : env[key];
|
|
66
77
|
}
|
|
67
78
|
|
|
@@ -70,6 +81,7 @@ function quoteArgument(arg: string): string {
|
|
|
70
81
|
if (/[\r\n]/.test(arg)) {
|
|
71
82
|
throw new Error('An argument to a batch file cannot hold a line break');
|
|
72
83
|
}
|
|
84
|
+
|
|
73
85
|
return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`;
|
|
74
86
|
}
|
|
75
87
|
|