@cspeach/cli 1.1.32 → 1.1.34

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 (101) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +61 -8
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +25 -8
  33. package/dist/commands/help.js +2 -1
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +112 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/month-usage.js +45 -0
  62. package/dist/cost/router-spend.js +3 -0
  63. package/dist/cost/session-cost.js +4 -0
  64. package/dist/doctor/checks/azure.js +100 -0
  65. package/dist/doctor/checks/credits.js +4 -3
  66. package/dist/doctor/checks/usage-reporting.js +22 -0
  67. package/dist/doctor/run.js +7 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/renderer/footer-line.js +7 -1
  76. package/dist/renderer/status-footer.js +13 -6
  77. package/dist/repl/ask-or-no.js +18 -0
  78. package/dist/repl/builtin-commands.js +12 -1
  79. package/dist/repl/post-turn-status.js +3 -2
  80. package/dist/repl/safety-confirm.js +47 -2
  81. package/dist/repl/session-spend-line.js +4 -1
  82. package/dist/repl/slash-completer.js +1 -0
  83. package/dist/repl/startup-account.js +51 -0
  84. package/dist/repl.js +223 -48
  85. package/dist/router/byok-classifier.js +41 -14
  86. package/dist/router/classifier.js +5 -16
  87. package/dist/router/router-target.js +41 -0
  88. package/dist/sap/onboarding.js +70 -64
  89. package/dist/session/audit-export.js +11 -4
  90. package/dist/skills/manifest-client.js +37 -4
  91. package/dist/skills/skills-cache.js +115 -0
  92. package/dist/skills/source-authed.js +276 -0
  93. package/dist/tools/filesystem/glob.js +12 -3
  94. package/dist/tools/filesystem/grep.js +14 -3
  95. package/dist/tools/tool-loading.js +9 -10
  96. package/dist/usage/usage-queue.js +323 -0
  97. package/dist/usage/usage-reporter.js +270 -0
  98. package/package.json +3 -2
  99. package/dist/skills/bundled-skills.js +0 -286
  100. package/dist/skills/source-bundled.js +0 -20
  101. package/dist/skills/source-manifest.js +0 -36
@@ -20,6 +20,7 @@ import { promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { registerTool } from '../index.js';
22
22
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, isReadOnlyProjectFile, isSeatReadOnlyFile, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
23
+ import { credentialFileRisk, credentialSkipNote } from '../../agent/risk-gate.js';
23
24
  import { isReadOnlyRel, isWorkRel, teamWorkRoot, teamWorkTarget } from '../_team-work-path.js';
24
25
  const MAX_RESULTS = 500;
25
26
  const MAX_FILES_TRAVERSED = 10_000;
@@ -120,6 +121,7 @@ export async function globHandler(args, ctx) {
120
121
  const wantsCspeach = isCspeachPattern(args.pattern);
121
122
  const collected = [];
122
123
  let budget = MAX_FILES_TRAVERSED;
124
+ let credentialSkipped = 0;
123
125
  async function walk(dir, relPrefix, denyRoot = root) {
124
126
  if (budget <= 0 || collected.length >= MAX_RESULTS * 4)
125
127
  return;
@@ -162,6 +164,12 @@ export async function globHandler(args, ctx) {
162
164
  const denylist = isDenylistedPath(abs, denyRoot);
163
165
  if (denylist.blocked && !isReadOnlyProjectFile(abs, denyRoot))
164
166
  continue;
167
+ // Security batch item 4, fix round 1 (F2) — credential files are not
168
+ // listed (same rule as the risk gate); one line says how many.
169
+ if (credentialFileRisk(ent.name)) {
170
+ credentialSkipped++;
171
+ continue;
172
+ }
165
173
  let mtime = 0;
166
174
  try {
167
175
  const s = await fs.stat(abs);
@@ -221,17 +229,18 @@ export async function globHandler(args, ctx) {
221
229
  // Sort by mtime descending, cap at MAX_RESULTS
222
230
  collected.sort((a, b) => b.mtime - a.mtime);
223
231
  const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
232
+ const skipNote = credentialSkipped > 0 ? `\n${credentialSkipNote(credentialSkipped)}` : '';
224
233
  if (sample.length === 0) {
225
234
  // A search for saved results that did not name `.cspeach/` gets the hint.
226
235
  const hint = !wantsCspeach && /\.json$/i.test(args.pattern)
227
236
  ? ' (saved CSPeach results live in .cspeach/ — search there with pattern ".cspeach/*.cspeach.json")'
228
237
  : '';
229
- return { content: `no files match "${args.pattern}"${hint}` };
238
+ return { content: `no files match "${args.pattern}"${hint}${skipNote}` };
230
239
  }
231
240
  const truncated = collected.length > MAX_RESULTS;
232
- const trailer = truncated
241
+ const trailer = (truncated
233
242
  ? `\n... (${collected.length - MAX_RESULTS} more matches — narrow the pattern)`
234
- : '';
243
+ : '') + skipNote;
235
244
  return { content: sample.join('\n') + trailer };
236
245
  }
237
246
  registerTool({
@@ -23,6 +23,7 @@ import { registerTool } from '../index.js';
23
23
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
24
24
  import { globToRegex } from './glob.js';
25
25
  import { teamWorkTarget } from '../_team-work-path.js';
26
+ import { credentialFileRisk, credentialSkipNote } from '../../agent/risk-gate.js';
26
27
  const MAX_MATCHES = 250;
27
28
  const MAX_FILES_TRAVERSED = 10_000;
28
29
  const MAX_FILE_BYTES = 2_000_000;
@@ -69,6 +70,11 @@ export async function grepHandler(args, ctx) {
69
70
  const matches = [];
70
71
  const fileMatchCounts = new Map();
71
72
  let budget = MAX_FILES_TRAVERSED;
73
+ // Security batch item 4, fix round 1 (F2) — credential files (.npmrc,
74
+ // .git-credentials, .env* outside the denylist, *.pem, *.key) are never
75
+ // searched: grep would return their text with no card. Counted, and one
76
+ // line says so. Same rule as the risk gate, so the lists cannot drift.
77
+ let credentialSkipped = 0;
72
78
  async function walk(dir, relPrefix) {
73
79
  if (budget <= 0 || matches.length >= MAX_MATCHES)
74
80
  return;
@@ -106,6 +112,10 @@ export async function grepHandler(args, ctx) {
106
112
  const denylist = isDenylistedPath(abs, root);
107
113
  if (denylist.blocked)
108
114
  continue;
115
+ if (credentialFileRisk(ent.name)) {
116
+ credentialSkipped++;
117
+ continue;
118
+ }
109
119
  let stat;
110
120
  try {
111
121
  stat = await fs.stat(abs);
@@ -158,13 +168,14 @@ export async function grepHandler(args, ctx) {
158
168
  }
159
169
  }
160
170
  await walk(realRoot, '');
171
+ const skipNote = credentialSkipped > 0 ? `\n${credentialSkipNote(credentialSkipped)}` : '';
161
172
  if (matches.length === 0) {
162
- return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}` };
173
+ return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}${skipNote}` };
163
174
  }
164
175
  const truncated = matches.length >= MAX_MATCHES;
165
- const trailer = truncated
176
+ const trailer = (truncated
166
177
  ? `\n... (cap of ${MAX_MATCHES} matches reached — narrow the pattern or use a glob filter)`
167
- : '';
178
+ : '') + skipNote;
168
179
  if (outputMode === 'files_with_matches') {
169
180
  const files = [...new Set(matches.map((m) => m.file))];
170
181
  return { content: files.join('\n') + trailer };
@@ -1,4 +1,5 @@
1
1
  import { toAnthropicTools } from './index.js';
2
+ import { profileForMode } from '../agent/connection-profile.js';
2
3
  /** The server-side tool search tool (probe P6: this exact name). */
3
4
  export const TOOL_SEARCH_TOOL = Object.freeze({
4
5
  type: 'tool_search_tool_regex_20251119',
@@ -105,25 +106,23 @@ export function modelAcceptsDeferredTools(model) {
105
106
  return DEFERRED_TOOL_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
106
107
  }
107
108
  /**
108
- * Mode precedence (ruling F13): AI-hub and local are always 'static' (their
109
- * wire has no deferred loading). Otherwise env `CSPEACH_TOOL_LOADING`
110
- * (`deferred` | `static`) wins; then managed uses the served value (default
111
- * 'static': only a proxy that serves tool_loading takes the new shapes);
112
- * BYOK uses the served value when it fetched one, else 'static'.
109
+ * Mode precedence (ruling F13), read from the connection profile since the
110
+ * Azure tenant mode (Task 1): 'static' profiles (AI-hub, local, azure v0) are
111
+ * always 'static'; otherwise env `CSPEACH_TOOL_LOADING` (`deferred` | `static`)
112
+ * wins; then 'served' profiles (managed, BYOK) use the served value (default
113
+ * 'static'); an unknown/fake mode ('env-only') stays 'static'.
113
114
  */
114
115
  export function resolveToolLoading(i) {
115
- if (i.providerMode === 'ai-hub' || i.providerMode === 'local')
116
+ const rule = (i.profile ?? profileForMode(i.providerMode)).deferredTools;
117
+ if (rule === 'static')
116
118
  return 'static';
117
119
  const raw = i.env.CSPEACH_TOOL_LOADING?.trim().toLowerCase();
118
120
  if (raw === 'deferred' || raw === 'static')
119
121
  return raw;
120
122
  // Task 21 fix round (controller ruling): an old proxy serves no tool_loading
121
123
  // and adds none of the deferred-tool betas, so 'deferred' only when served.
122
- if (i.providerMode === 'managed')
124
+ if (rule === 'served')
123
125
  return i.served ?? 'static';
124
- if (i.providerMode === 'byok')
125
- return i.served ?? 'static';
126
- // Unknown/fake provider modes keep today's full list.
127
126
  return 'static';
128
127
  }
129
128
  /**
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Security batch item 3 — <CSPeach home>/usage-queue.jsonl: usage counts
3
+ * waiting to be sent. Capped at 1,000 lines or 1 MB (oldest dropped).
4
+ *
5
+ * Safe with several cspeach processes: every read-modify-write holds an
6
+ * exclusive lock file (O_EXCL create). The holder writes "<pid> <ms> <host>
7
+ * <token>"; a contender clears the lock at once when that pid is gone on this
8
+ * host, or when the lock is older than 10 s. A contender waits longer than
9
+ * the stale age, so an orphaned lock never drops a record (F5).
10
+ *
11
+ * The network send happens OUTSIDE the lock as a lease (F1): leaseBatch
12
+ * returns the oldest records WITHOUT removing them; ackBatch removes exactly
13
+ * those request ids once the proxy answered. A crash mid-send therefore
14
+ * resends the batch next time, and the proxy drops the repeat by request id.
15
+ *
16
+ * Each line carries a local-only `keyPrefix` (a short sha256 fingerprint of
17
+ * the seat's key, never the key or a piece of it). leaseBatch keeps only the
18
+ * current seat's lines, drops the rest, and strips the tag: it is never sent
19
+ * (F10).
20
+ */
21
+ import { promises as fs } from 'node:fs';
22
+ import { createHash, randomUUID } from 'node:crypto';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { cspeachRoot } from '../config/paths.js';
26
+ import { countedTokens } from '../connection/local-usage.js';
27
+ /** The exact fields sent; anything else on a queue line stays on disk. */
28
+ const RECORD_KEYS = [
29
+ 'request_id', 'mode', 'skill', 'model', 'input_tokens', 'output_tokens',
30
+ 'cache_read_tokens', 'cache_write_tokens', 'cli_version', 'session_id', 'at',
31
+ ];
32
+ export const QUEUE_MAX_LINES = 1000;
33
+ export const QUEUE_MAX_BYTES = 1_000_000;
34
+ const LOCK_STALE_MS = 10_000;
35
+ /** Longer than LOCK_STALE_MS: a holder that died is always outwaited (F5). */
36
+ const LOCK_WAIT_MS = 15_000;
37
+ /**
38
+ * Final review finding 6 — the rename retry runs while the lock is held, so it
39
+ * must give up before a contender may judge that lock stale (LOCK_STALE_MS):
40
+ * otherwise two writers are possible in the last seconds.
41
+ */
42
+ const RENAME_WAIT_MS = 8_000;
43
+ /** A queue temp file older than this is a crash leftover (a live write renames within ms). */
44
+ const STALE_TMP_MS = 60_000;
45
+ /** Pinned by tests: RENAME_WAIT_MS < LOCK_STALE_MS < LOCK_WAIT_MS. */
46
+ export const QUEUE_TIMINGS = { LOCK_STALE_MS, LOCK_WAIT_MS, RENAME_WAIT_MS };
47
+ const LOCK_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
48
+ export function usageQueuePath() {
49
+ return path.join(cspeachRoot(), 'usage-queue.jsonl');
50
+ }
51
+ const lockPath = () => `${usageQueuePath()}.lock`;
52
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
53
+ /** The seat tag on a queue line: a fingerprint of the key, never the key. */
54
+ export function keyPrefixFor(key) {
55
+ return createHash('sha256').update(`cspeach-usage-seat:${key}`).digest('hex').slice(0, 16);
56
+ }
57
+ /** True only when the lock's holder is provably gone (same host, pid not running). */
58
+ function holderIsDead(content) {
59
+ const [pidText, , host] = content.trim().split(' ');
60
+ const pid = Number(pidText);
61
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
62
+ return false;
63
+ if (host !== os.hostname())
64
+ return false;
65
+ try {
66
+ process.kill(pid, 0);
67
+ return false;
68
+ }
69
+ catch (err) {
70
+ return err.code === 'ESRCH';
71
+ }
72
+ }
73
+ /**
74
+ * Remove a lock judged dead or stale. Renamed to a unique name first (only one
75
+ * contender can win the rename); if what was renamed is not the lock that was
76
+ * judged (a fresh holder took over in between), it is put back.
77
+ */
78
+ async function clearLock(lp, judged) {
79
+ const aside = `${lp}.${process.pid}.${randomUUID()}.stale`;
80
+ try {
81
+ await fs.rename(lp, aside);
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ const got = await fs.readFile(aside, 'utf-8').catch(() => judged);
87
+ if (got !== judged)
88
+ await fs.link(aside, lp).catch(() => { });
89
+ await fs.rm(aside, { force: true }).catch(() => { });
90
+ }
91
+ async function withQueueLock(fn) {
92
+ const lp = lockPath();
93
+ await fs.mkdir(path.dirname(lp), { recursive: true });
94
+ const token = `${process.pid} ${Date.now()} ${os.hostname()} ${randomUUID()}`;
95
+ const deadline = Date.now() + LOCK_WAIT_MS;
96
+ for (;;) {
97
+ try {
98
+ const h = await fs.open(lp, 'wx');
99
+ try {
100
+ await h.writeFile(token);
101
+ }
102
+ finally {
103
+ await h.close();
104
+ }
105
+ break;
106
+ }
107
+ catch (err) {
108
+ const code = err.code ?? '';
109
+ // F14 — on Windows a lock file being created or deleted by another
110
+ // process answers EPERM / EACCES / EBUSY instead of EEXIST: that is
111
+ // contention too, so wait and retry (never drop the record).
112
+ if (code === 'EEXIST') {
113
+ try {
114
+ const [content, st] = await Promise.all([fs.readFile(lp, 'utf-8'), fs.stat(lp)]);
115
+ if (holderIsDead(content) || Date.now() - st.mtimeMs > LOCK_STALE_MS) {
116
+ await clearLock(lp, content);
117
+ continue;
118
+ }
119
+ }
120
+ catch { /* gone or busy: retry after the pause */ }
121
+ }
122
+ else if (!LOCK_CODES.has(code)) {
123
+ return undefined;
124
+ }
125
+ if (Date.now() > deadline)
126
+ return undefined;
127
+ await sleep(10 + Math.floor(Math.random() * 20));
128
+ }
129
+ }
130
+ await sweepStaleTemps();
131
+ try {
132
+ return await fn();
133
+ }
134
+ finally {
135
+ // Release only our own lock (it may have been cleared as stale meanwhile).
136
+ const now = await fs.readFile(lp, 'utf-8').catch(() => '');
137
+ if (now === token)
138
+ await fs.unlink(lp).catch(() => { });
139
+ }
140
+ }
141
+ /**
142
+ * Task 9 I3 — remove usage-queue.jsonl.<pid>.tmp files left by a crash.
143
+ * Runs under the queue lock; only our own temp names, only when older than
144
+ * STALE_TMP_MS (same rule as the skills cache). Never throws.
145
+ */
146
+ async function sweepStaleTemps() {
147
+ const p = usageQueuePath();
148
+ const dir = path.dirname(p);
149
+ const prefix = `${path.basename(p)}.`;
150
+ try {
151
+ for (const f of await fs.readdir(dir)) {
152
+ if (!f.startsWith(prefix) || !/^\d+\.tmp$/.test(f.slice(prefix.length)))
153
+ continue;
154
+ const full = path.join(dir, f);
155
+ const st = await fs.stat(full).catch(() => null);
156
+ if (st && Date.now() - st.mtimeMs > STALE_TMP_MS)
157
+ await fs.rm(full, { force: true }).catch(() => { });
158
+ }
159
+ }
160
+ catch { /* no directory yet */ }
161
+ }
162
+ async function readLines() {
163
+ try {
164
+ return (await fs.readFile(usageQueuePath(), 'utf-8')).split('\n').filter((l) => l.trim().length > 0);
165
+ }
166
+ catch {
167
+ return [];
168
+ }
169
+ }
170
+ async function writeLines(lines) {
171
+ const p = usageQueuePath();
172
+ if (lines.length === 0) {
173
+ await fs.rm(p, { force: true });
174
+ return;
175
+ }
176
+ const tmp = `${p}.${process.pid}.tmp`;
177
+ await fs.writeFile(tmp, lines.join('\n') + '\n', 'utf-8');
178
+ // F14 — a reader outside the lock (queueStats, doctor) can hold the file
179
+ // open on Windows for a moment: EPERM / EACCES / EBUSY is retried until a
180
+ // deadline, never treated as a lost write.
181
+ const deadline = Date.now() + RENAME_WAIT_MS;
182
+ for (let attempt = 0;; attempt++) {
183
+ try {
184
+ await fs.rename(tmp, p);
185
+ return;
186
+ }
187
+ catch (err) {
188
+ if (!LOCK_CODES.has(err.code ?? '') || Date.now() > deadline) {
189
+ await fs.rm(tmp, { force: true }).catch(() => { });
190
+ throw err;
191
+ }
192
+ await sleep(Math.min(20 * (attempt + 1), 200));
193
+ }
194
+ }
195
+ }
196
+ /** Newest lines kept: at most QUEUE_MAX_LINES and QUEUE_MAX_BYTES (newline included). */
197
+ export function capLines(lines) {
198
+ let out = lines.slice(-QUEUE_MAX_LINES);
199
+ let bytes = out.reduce((n, l) => n + Buffer.byteLength(l) + 1, 0);
200
+ while (out.length > 0 && bytes > QUEUE_MAX_BYTES) {
201
+ bytes -= Buffer.byteLength(out[0]) + 1;
202
+ out = out.slice(1);
203
+ }
204
+ return out;
205
+ }
206
+ /** A line → the record (exactly RECORD_KEYS) and its seat tag; null when unreadable. */
207
+ function parseLine(l) {
208
+ try {
209
+ const o = JSON.parse(l);
210
+ if (!o || typeof o !== 'object' || Array.isArray(o) || typeof o.request_id !== 'string')
211
+ return null;
212
+ const rec = Object.fromEntries(RECORD_KEYS.map((k) => [k, o[k]]));
213
+ return { rec, keyPrefix: typeof o.keyPrefix === 'string' ? o.keyPrefix : '' };
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ }
219
+ /** True when the record is in the queue, tagged with its seat. Never throws. */
220
+ export async function enqueueUsage(rec, keyPrefix) {
221
+ try {
222
+ const line = JSON.stringify({ ...rec, keyPrefix });
223
+ return (await withQueueLock(async () => { await writeLines(capLines([...(await readLines()), line])); return true; })) === true;
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ }
229
+ /**
230
+ * Up to `max` of the oldest records of this seat, NOT removed (F1). Lines
231
+ * that cannot be read and lines of another seat are dropped here (F10); the
232
+ * returned records never carry the seat tag. Never throws.
233
+ */
234
+ export async function leaseBatch(max, keyPrefix) {
235
+ try {
236
+ return (await withQueueLock(async () => {
237
+ const lines = await readLines();
238
+ const keep = [];
239
+ const out = [];
240
+ for (const l of lines) {
241
+ const q = parseLine(l);
242
+ if (!q || q.keyPrefix !== keyPrefix)
243
+ continue;
244
+ keep.push(l);
245
+ if (out.length < max)
246
+ out.push(q.rec);
247
+ }
248
+ if (keep.length !== lines.length)
249
+ await writeLines(keep);
250
+ return out;
251
+ })) ?? [];
252
+ }
253
+ catch {
254
+ return [];
255
+ }
256
+ }
257
+ /** Remove the records with these request ids (the proxy answered for them). False when the queue could not be changed. */
258
+ export async function ackBatch(requestIds) {
259
+ if (requestIds.length === 0)
260
+ return true;
261
+ const done = new Set(requestIds);
262
+ try {
263
+ return (await withQueueLock(async () => {
264
+ const lines = await readLines();
265
+ const keep = lines.filter((l) => { const q = parseLine(l); return q !== null && !done.has(q.rec.request_id); });
266
+ if (keep.length !== lines.length)
267
+ await writeLines(keep);
268
+ return true;
269
+ })) === true;
270
+ }
271
+ catch {
272
+ return false;
273
+ }
274
+ }
275
+ export async function queueStats() {
276
+ try {
277
+ const text = await fs.readFile(usageQueuePath(), 'utf-8');
278
+ return { lines: text.split('\n').filter((l) => l.trim().length > 0).length, bytes: Buffer.byteLength(text) };
279
+ }
280
+ catch {
281
+ return { lines: 0, bytes: 0 };
282
+ }
283
+ }
284
+ /**
285
+ * Azure limit (D6) — the counted tokens (input + output + cache-write; cache
286
+ * reads excluded) of THIS seat's records still waiting in the queue: exactly
287
+ * the tokens the server's `used` does not hold yet. Only azure records count,
288
+ * as the server's `used` counts azure usage only. Reads without the lock,
289
+ * as queueStats does. No file → 0; any other read error throws, so the caller
290
+ * keeps its count. Unreadable lines, other seats' lines and other modes are skipped.
291
+ */
292
+ export async function queuedTokens(keyPrefix) {
293
+ let text;
294
+ try {
295
+ text = await fs.readFile(usageQueuePath(), 'utf-8');
296
+ }
297
+ catch (err) {
298
+ if (err.code === 'ENOENT')
299
+ return 0;
300
+ throw err;
301
+ }
302
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
303
+ let sum = 0;
304
+ for (const l of text.split('\n')) {
305
+ if (l.trim().length === 0)
306
+ continue;
307
+ const q = parseLine(l);
308
+ if (!q || q.keyPrefix !== keyPrefix || q.rec.mode !== 'azure')
309
+ continue;
310
+ sum += countedTokens({ input_tokens: n(q.rec.input_tokens), output_tokens: n(q.rec.output_tokens), cache_write_tokens: n(q.rec.cache_write_tokens) });
311
+ }
312
+ return sum;
313
+ }
314
+ /** Cheap when there is nothing to delete (no lock taken): called on every call while reporting is off. */
315
+ export async function deleteUsageQueue() {
316
+ try {
317
+ await fs.access(usageQueuePath());
318
+ }
319
+ catch {
320
+ return;
321
+ }
322
+ await withQueueLock(async () => { await fs.rm(usageQueuePath(), { force: true }); }).catch(() => { });
323
+ }