@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.
Files changed (172) hide show
  1. package/dist/async.d.ts +5 -0
  2. package/dist/async.js +13 -5
  3. package/dist/chat/acp-protocol.js +3 -5
  4. package/dist/chat/chat-core.js +0 -2
  5. package/dist/chat/chat-process.d.ts +1 -0
  6. package/dist/chat/chat-process.js +1 -1
  7. package/dist/chat/chat-session.js +6 -8
  8. package/dist/chat/claude-protocol.js +1 -6
  9. package/dist/chat/claude-transcript.js +1 -3
  10. package/dist/chat/codex-protocol.js +1 -6
  11. package/dist/chat/projector.js +1 -3
  12. package/dist/chat/request-summary.js +1 -3
  13. package/dist/coded-error.js +1 -5
  14. package/dist/context/argv.js +1 -2
  15. package/dist/context/verb.js +1 -3
  16. package/dist/error-text.d.ts +1 -0
  17. package/dist/error-text.js +4 -0
  18. package/dist/fields.d.ts +3 -0
  19. package/dist/fields.js +12 -0
  20. package/dist/fs.d.ts +1 -0
  21. package/dist/fs.js +8 -4
  22. package/dist/lineage.js +11 -5
  23. package/dist/messages/notice-store.js +3 -4
  24. package/dist/providers/accounts/acp-account.js +3 -20
  25. package/dist/providers/accounts/service.d.ts +1 -2
  26. package/dist/providers/accounts/service.js +5 -9
  27. package/dist/providers/accounts/status.js +1 -3
  28. package/dist/providers/acp-agent-state.js +1 -3
  29. package/dist/providers/claude.js +0 -1
  30. package/dist/providers/codex-models.json +3 -2
  31. package/dist/providers/codex.js +0 -1
  32. package/dist/providers/detect.js +1 -1
  33. package/dist/providers/shell-commands.js +1 -2
  34. package/dist/run-process.js +1 -3
  35. package/dist/skills.d.ts +1 -1
  36. package/dist/skills.js +5 -8
  37. package/dist/tasks/background-limit.js +3 -2
  38. package/dist/tasks/task-coordinator.js +11 -17
  39. package/dist/testing/watch.js +0 -1
  40. package/dist/usage/limits/monitor.js +1 -2
  41. package/dist/usage/prices-snapshot.json +8 -0
  42. package/dist/usage/readers/claude.js +3 -10
  43. package/dist/usage/readers/codex.js +2 -9
  44. package/dist/usage/readers/grok.js +3 -10
  45. package/dist/usage/readers/host-log.js +3 -10
  46. package/dist/usage/record.d.ts +2 -0
  47. package/dist/usage/record.js +13 -0
  48. package/package.json +6 -6
  49. package/src/async.ts +23 -5
  50. package/src/chat/acp-backend.ts +121 -0
  51. package/src/chat/acp-protocol.ts +172 -6
  52. package/src/chat/attachment-store.ts +21 -0
  53. package/src/chat/backend.ts +0 -2
  54. package/src/chat/bookmark-store.ts +23 -0
  55. package/src/chat/chat-core.ts +208 -7
  56. package/src/chat/chat-log.ts +19 -0
  57. package/src/chat/chat-process.ts +52 -1
  58. package/src/chat/chat-session.ts +401 -10
  59. package/src/chat/chat-store.ts +50 -0
  60. package/src/chat/chat-title.ts +9 -0
  61. package/src/chat/claude-backend.ts +50 -0
  62. package/src/chat/claude-protocol.ts +155 -8
  63. package/src/chat/claude-title.ts +10 -0
  64. package/src/chat/claude-transcript.ts +47 -4
  65. package/src/chat/codex-backend.ts +131 -0
  66. package/src/chat/codex-protocol.ts +180 -8
  67. package/src/chat/codex-thread.ts +23 -0
  68. package/src/chat/composer-preferences.ts +6 -0
  69. package/src/chat/context-breakdown.ts +11 -0
  70. package/src/chat/delta-coalescer.ts +8 -0
  71. package/src/chat/external-sessions.ts +28 -0
  72. package/src/chat/generated-image.ts +25 -0
  73. package/src/chat/index.ts +5 -0
  74. package/src/chat/input.ts +13 -0
  75. package/src/chat/json-rpc-transport.ts +28 -0
  76. package/src/chat/limit-resume.ts +13 -0
  77. package/src/chat/projector.ts +160 -4
  78. package/src/chat/readable-text.ts +3 -0
  79. package/src/chat/request-summary.ts +18 -4
  80. package/src/chat/subagent-projection.ts +3 -0
  81. package/src/chat/subagent-reader.ts +76 -0
  82. package/src/chat/subagent-settlement.ts +24 -0
  83. package/src/chat/thread-search.ts +13 -0
  84. package/src/chat/thread.ts +31 -0
  85. package/src/chat/ui-queries.ts +122 -0
  86. package/src/chat/ui-replies.ts +34 -0
  87. package/src/chat/visual-store.ts +49 -0
  88. package/src/chat/wake-chat.ts +3 -0
  89. package/src/client-sinks.ts +2 -0
  90. package/src/coded-error.ts +1 -5
  91. package/src/context/argv.ts +18 -2
  92. package/src/context/index.ts +1 -0
  93. package/src/context/refusal.ts +3 -0
  94. package/src/context/verb.ts +32 -4
  95. package/src/coordination/index.ts +10 -0
  96. package/src/error-text.ts +7 -0
  97. package/src/fields.ts +14 -0
  98. package/src/fs.ts +20 -4
  99. package/src/host/agent-host.ts +20 -0
  100. package/src/host/environment.ts +6 -0
  101. package/src/host/handlers.ts +18 -0
  102. package/src/host/memory-port.ts +4 -0
  103. package/src/host/wiring.ts +10 -0
  104. package/src/index.ts +1 -0
  105. package/src/lineage.ts +24 -5
  106. package/src/messages/deliver-message.ts +6 -0
  107. package/src/messages/deliver-notice.ts +13 -0
  108. package/src/messages/notice-store.ts +16 -4
  109. package/src/modes.ts +1 -0
  110. package/src/outbox/outbox-worker.ts +31 -0
  111. package/src/outbox/outbox.ts +5 -0
  112. package/src/providers/accounts/accounts.ts +36 -0
  113. package/src/providers/accounts/acp-account.ts +22 -27
  114. package/src/providers/accounts/launch.ts +4 -0
  115. package/src/providers/accounts/service.ts +110 -12
  116. package/src/providers/accounts/shadow-home.ts +10 -0
  117. package/src/providers/accounts/status.ts +13 -4
  118. package/src/providers/accounts/store.ts +25 -0
  119. package/src/providers/accounts/variables.ts +11 -1
  120. package/src/providers/acp-agent-state.ts +24 -3
  121. package/src/providers/acp-provider.ts +5 -0
  122. package/src/providers/acp-usage-log.ts +2 -0
  123. package/src/providers/catalog.ts +17 -0
  124. package/src/providers/claude.ts +10 -1
  125. package/src/providers/codex-models.json +3 -2
  126. package/src/providers/codex-rules.ts +3 -0
  127. package/src/providers/codex.ts +2 -1
  128. package/src/providers/detect.ts +6 -1
  129. package/src/providers/provider.ts +1 -1
  130. package/src/providers/registry.ts +18 -2
  131. package/src/providers/shell-commands.ts +37 -2
  132. package/src/record-directory.ts +19 -0
  133. package/src/run-process.ts +24 -3
  134. package/src/serializer.ts +3 -0
  135. package/src/skills.ts +73 -10
  136. package/src/tasks/background-limit.ts +9 -2
  137. package/src/tasks/end-children.ts +9 -0
  138. package/src/tasks/give-task.ts +8 -0
  139. package/src/tasks/task-coordinator.ts +81 -17
  140. package/src/tasks/task-store.ts +28 -0
  141. package/src/tasks/waiting-child.ts +21 -0
  142. package/src/tasks/wake-parent.ts +26 -0
  143. package/src/tasks/wiring.ts +22 -0
  144. package/src/testing/accounts.ts +2 -0
  145. package/src/testing/fake-acp-capture.ts +16 -0
  146. package/src/testing/fake-acp.ts +66 -0
  147. package/src/testing/fake-claude.ts +177 -0
  148. package/src/testing/fake-cli.ts +29 -0
  149. package/src/testing/fake-codex.ts +122 -0
  150. package/src/testing/manual-clock.ts +2 -0
  151. package/src/testing/watch.ts +7 -1
  152. package/src/title-file.ts +12 -0
  153. package/src/usage/accounts.ts +6 -0
  154. package/src/usage/aggregate.ts +19 -0
  155. package/src/usage/exchange.ts +12 -0
  156. package/src/usage/index-file.ts +27 -0
  157. package/src/usage/limits/monitor.ts +31 -3
  158. package/src/usage/limits/normalize.ts +30 -0
  159. package/src/usage/limits/probe.ts +17 -0
  160. package/src/usage/prices-snapshot.json +8 -0
  161. package/src/usage/pricing.ts +35 -0
  162. package/src/usage/projects.ts +8 -0
  163. package/src/usage/readers/claude.ts +12 -9
  164. package/src/usage/readers/codex.ts +40 -8
  165. package/src/usage/readers/grok.ts +17 -9
  166. package/src/usage/readers/host-log.ts +9 -9
  167. package/src/usage/record.ts +22 -0
  168. package/src/usage/roots.ts +6 -0
  169. package/src/usage/scanner.ts +61 -0
  170. package/src/usage/usage-service.ts +14 -0
  171. package/src/watch-seam.ts +8 -0
  172. package/src/windows-command.ts +12 -0
@@ -1,5 +1,5 @@
1
1
  import type { UsageProvider } from '@adecore/agent-contracts';
2
- import { asObject, asString, int, type UsageRecord } from '../record.ts';
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
- let parsed: unknown;
36
- try {
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
- if (totals.input + totals.cacheRead + totals.cacheWrite + totals.output === 0) {
57
+
58
+ if (!countsTokens(totals)) {
60
59
  return null;
61
60
  }
61
+
62
62
  return {
63
63
  provider,
64
64
  timestampMs,
@@ -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
  }
@@ -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
  }
@@ -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
  }
@@ -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