@cspeach/cli 1.1.33 → 1.1.35

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 (119) 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 +182 -39
  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 +19 -6
  33. package/dist/commands/help.js +2 -2
  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 +6 -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/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -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/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -19,79 +19,78 @@ import stringWidth from 'string-width';
19
19
  import { roleProps, selectedProps } from '../role-props.js';
20
20
  import { getBoxChars } from '../ascii-chars.js';
21
21
  import { getGlyphs } from '../../renderer/glyphs.js';
22
- import { truncateToWidth } from '../../renderer/ui-width.js';
22
+ import { truncateToWidth, uiWidth } from '../../renderer/ui-width.js';
23
+ import { wrapRows } from '../../renderer/wrap-rows.js';
23
24
  import { sanitizeControlChars } from '../../renderer/sanitize.js';
24
25
  // Q1b's shared hook — the one raw-stdin reclaim for every card (no second copy).
25
26
  import { useReclaimRawStdin } from './reclaim-raw-stdin.js';
26
27
  export const CONFIRM_INPUT_GUARD_MS = 150;
28
+ /** Facts wrap onto this many rows unless the request says otherwise (fix batch 2026-10-12). */
29
+ export const DEFAULT_WRAP_FACTS = 3;
30
+ /** A title wraps onto at most this many rows. */
31
+ export const TITLE_MAX_ROWS = 2;
32
+ /** A choice label wraps onto at most this many rows. */
33
+ export const LABEL_MAX_ROWS = 2;
34
+ /** `${cursor} ${n}. ` — the same width whether the cursor is on the row or not. */
35
+ const choicePrefixWidth = (i) => 2 + `${i + 1}. `.length;
27
36
  /**
28
- * The card's full rendered height for the live-area budget (F3 liveBudget
29
- * `cardRows`): top edge + title + one row per fact + the gap above the
30
- * choices + one row per choice + the hint + bottom edge. Every row is
31
- * truncate-end, so nothing wraps into extra rows — except the facts of a
32
- * card that asks for `wrapFacts`: with `width` given, each counts the rows
33
- * it is drawn on.
37
+ * A choice's rows. The label wraps under its own text (continuation rows are
38
+ * indented past the number). The dim hint stays beside the last label row
39
+ * when it fits; otherwise it gets a row of its own — a hint such as "DEV
40
+ * systems only" is a safety cue and is never dropped.
34
41
  */
35
- export function estimateConfirmCardRows(req, width, maxRows) {
36
- const facts = req.facts ?? [];
37
- const wrap = width === undefined ? 1 : fittedWrap(req, width, maxRows);
38
- const factRowCount = width !== undefined && wrap > 1
39
- ? facts.reduce((n, f) => n + factRows(f, Math.max(1, width - 4), wrap).length, 0)
40
- : facts.length;
41
- return 5 + factRowCount + req.choices.length;
42
+ export function choiceRows(label, hint, i, inner, labelRows) {
43
+ const p = choicePrefixWidth(i);
44
+ const rows = wrapRows(label, Math.max(1, inner - p), labelRows);
45
+ if (!hint)
46
+ return { label: rows, hint: '', hintOwnRow: false };
47
+ const room = inner - p - stringWidth(rows[rows.length - 1]);
48
+ const beside = ` ${sanitizeControlChars(hint)}`;
49
+ if (stringWidth(beside) <= room)
50
+ return { label: rows, hint: beside, hintOwnRow: false };
51
+ return { label: rows, hint: truncateToWidth(sanitizeControlChars(hint), Math.max(1, inner - p)), hintOwnRow: true };
52
+ }
53
+ function rowsFor(req, inner, l) {
54
+ const title = wrapRows(req.title, inner, l.titleRows).length;
55
+ const facts = (req.facts ?? []).reduce((n, f) => n + wrapRows(f, inner, l.wrap).length, 0);
56
+ const choices = req.choices.reduce((n, c, i) => {
57
+ const r = choiceRows(c.label, c.hint, i, inner, l.labelRows);
58
+ return n + r.label.length + (r.hintOwnRow ? 1 : 0);
59
+ }, 0);
60
+ // top edge + title + facts + gap + choices + hint + bottom edge
61
+ return 4 + title + facts + choices;
42
62
  }
43
63
  /**
44
- * The rows a long fact may wrap onto so the whole card stays within
45
- * `maxRows` (App: rows − 6, so Ink never reaches its full-clear path): the
46
- * card's own `wrapFacts`, one less each time it would not fit, down to one
47
- * row (every fact cut with …, as on any card).
64
+ * The layout that keeps the whole card within `maxRows` (App: rows − 6, so
65
+ * Ink never reaches its full-clear path). Card content wraps; when it would
66
+ * not fit, the facts give up rows first (one less each time, down to one
67
+ * row cut with …), then the title, then the choice labels.
48
68
  */
49
- export function fittedWrap(req, width, maxRows) {
50
- let wrap = Math.max(1, req.wrapFacts ?? 1);
69
+ export function confirmLayout(req, width, maxRows) {
70
+ const l = { wrap: Math.max(1, req.wrapFacts ?? DEFAULT_WRAP_FACTS), titleRows: TITLE_MAX_ROWS, labelRows: LABEL_MAX_ROWS };
51
71
  if (maxRows === undefined)
52
- return wrap;
53
- const cells = Math.max(1, width - 4);
54
- const rowsAt = (w) => 5 + req.choices.length + (req.facts ?? []).reduce((n, f) => n + factRows(f, cells, w).length, 0);
55
- while (wrap > 1 && rowsAt(wrap) > maxRows)
56
- wrap -= 1;
57
- return wrap;
72
+ return l;
73
+ const inner = Math.max(1, width - 4);
74
+ while (l.wrap > 1 && rowsFor(req, inner, l) > maxRows)
75
+ l.wrap -= 1;
76
+ if (rowsFor(req, inner, l) > maxRows)
77
+ l.titleRows = 1;
78
+ if (rowsFor(req, inner, l) > maxRows)
79
+ l.labelRows = 1;
80
+ return l;
58
81
  }
59
- const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
60
82
  /**
61
- * One fact as the rows it is drawn on. `rows` 1 (the default): one row, cut
62
- * with … — how every card looks. More: wrapped at spaces (a word wider than
63
- * the row is cut where it must be) onto at most `rows` rows, the last one cut
64
- * with … when there is still more. Control characters are neutralised first.
83
+ * The card's full rendered height for the live-area budget (F3 liveBudget
84
+ * `cardRows`): top edge + title rows + every fact row + the gap above the
85
+ * choices + every choice row + the hint + bottom edge. Every wrapped row is
86
+ * counted; `width` defaults to uiWidth().
65
87
  */
66
- export function factRows(text, cells, rows = 1) {
67
- const flat = sanitizeControlChars(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
68
- if (rows <= 1 || stringWidth(flat) <= cells)
69
- return [truncateToWidth(flat, cells)];
70
- const out = [];
71
- let rest = flat;
72
- while (out.length < rows - 1 && stringWidth(rest) > cells) {
73
- let used = 0;
74
- let space = -1;
75
- let end = rest.length;
76
- for (const { segment, index } of graphemes.segment(rest)) {
77
- const w = stringWidth(segment);
78
- if (used + w > cells) {
79
- end = index;
80
- if (segment === ' ')
81
- space = index;
82
- break;
83
- }
84
- used += w;
85
- if (segment === ' ')
86
- space = index;
87
- }
88
- const at = space > 0 ? space : Math.max(1, end);
89
- out.push(rest.slice(0, at).trimEnd());
90
- rest = rest.slice(at).trimStart();
91
- }
92
- if (rest)
93
- out.push(truncateToWidth(rest, cells));
94
- return out;
88
+ export function estimateConfirmCardRows(req, width = uiWidth(), maxRows) {
89
+ return rowsFor(req, Math.max(1, width - 4), confirmLayout(req, width, maxRows));
90
+ }
91
+ /** The rows a long fact may wrap onto so the whole card stays within `maxRows`. */
92
+ export function fittedWrap(req, width, maxRows) {
93
+ return confirmLayout(req, width, maxRows).wrap;
95
94
  }
96
95
  /**
97
96
  * Owner decision (fix-round-3 deferred M1): a digit PICKS only on ordinary
@@ -155,17 +154,43 @@ export function matchAlias(aliases, input, word) {
155
154
  function fit(text, cells) {
156
155
  return truncateToWidth(sanitizeControlChars(text), cells);
157
156
  }
158
- const inkBorder = (b) => ({
159
- topLeft: b.tl, top: b.h, topRight: b.tr, right: b.v,
160
- bottomRight: b.br, bottom: b.h, bottomLeft: b.bl, left: b.v,
161
- });
162
- /** The frame every card in the slot shares: tag chip top edge, footnote bottom edge. */
163
- export function CardFrame({ tag, footnote, width, children }) {
157
+ /**
158
+ * One border object per box-character set, reused on every render. Ink diffs
159
+ * style props by identity: a new borderStyle object on a re-render is sent
160
+ * alone, without borderTop/borderBottom false, and Ink then gives the box a
161
+ * top and bottom border again (two blank rows inside the frame after the
162
+ * first key press). Task 4 found it when AskCard moved into this frame.
163
+ */
164
+ const borderCache = new Map();
165
+ function makeInkBorder(b) {
166
+ return {
167
+ topLeft: b.tl, top: b.h, topRight: b.tr, right: b.v,
168
+ bottomRight: b.br, bottom: b.h, bottomLeft: b.bl, left: b.v,
169
+ };
170
+ }
171
+ const inkBorder = (b) => {
172
+ let out = borderCache.get(b);
173
+ if (!out) {
174
+ out = makeInkBorder(b);
175
+ borderCache.set(b, out);
176
+ }
177
+ return out;
178
+ };
179
+ /**
180
+ * The frame every card in the slot shares: tag chip top edge, footnote bottom
181
+ * edge. `header` (Task 4, e.g. "Blocker 2 of 4") follows the tag on the top
182
+ * edge, dim: "╭─ QUESTION · Blocker 2 of 4 ───╮"; it is cut first when narrow.
183
+ */
184
+ export function CardFrame({ tag, header, footnote, width, children }) {
164
185
  const box = getBoxChars();
165
186
  const frame = roleProps('frame');
166
- const top = edgeSegments([box.tl, box.tr], box.h, tag, width);
187
+ const extra = header ? ` · ${sanitizeControlChars(header).replace(/[\t\n]+/g, ' ')}` : '';
188
+ const top = edgeSegments([box.tl, box.tr], box.h, `${tag}${extra}`, width);
189
+ // The shown label is cut from the end, so the tag (if whole) leads it.
190
+ const tagShown = top.label.startsWith(tag) ? tag : top.label;
191
+ const headerShown = top.label.slice(tagShown.length);
167
192
  const bottom = edgeSegments([box.bl, box.br], box.h, footnote, width);
168
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: top.lead }), _jsx(Text, { ...roleProps(TAG_ROLE[tag], { bold: true }), children: top.label }), _jsx(Text, { ...frame, children: top.tail })] }), _jsx(Box, { flexDirection: "column", width: width, paddingX: 1, borderStyle: inkBorder(box), borderTop: false, borderBottom: false, borderColor: frame.color, children: children }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: bottom.lead }), _jsx(Text, { ...roleProps('dim'), children: bottom.label }), _jsx(Text, { ...frame, children: bottom.tail })] })] }));
193
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: top.lead }), _jsx(Text, { ...roleProps(TAG_ROLE[tag], { bold: true }), children: tagShown }), headerShown ? _jsx(Text, { ...roleProps('dim'), children: headerShown }) : null, _jsx(Text, { ...frame, children: top.tail })] }), _jsx(Box, { flexDirection: "column", width: width, paddingX: 1, borderStyle: inkBorder(box), borderTop: false, borderBottom: false, borderColor: frame.color, children: children }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: bottom.lead }), _jsx(Text, { ...roleProps('dim'), children: bottom.label }), _jsx(Text, { ...frame, children: bottom.tail })] })] }));
169
194
  }
170
195
  export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTurn }) {
171
196
  // Re-assert raw stdin on mount: an earlier inquirer prompt can leave Ink's
@@ -235,7 +260,7 @@ export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTur
235
260
  finish({ value: hit });
236
261
  });
237
262
  const inner = Math.max(1, width - 4);
238
- const wrap = fittedWrap(request, width, maxRows);
263
+ const layout = confirmLayout(request, width, maxRows);
239
264
  const dim = roleProps('dim');
240
265
  const titleProps = request.tag === 'CANNOT BE UNDONE' ? roleProps('bad', { bold: true }) : { bold: true };
241
266
  const letters = Object.keys(request.aliases ?? {}).filter((k) => k.length === 1);
@@ -244,13 +269,14 @@ export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTur
244
269
  ? `1-${digits} picks · ↑↓ move · Enter chooses · Esc cancels`
245
270
  : `Enter chooses · Esc cancels · ↑↓ or 1-${digits} move`)
246
271
  + (letters.length > 0 ? ` · keys ${letters.join('/')}` : '');
247
- return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [_jsx(Text, { wrap: "truncate-end", ...titleProps, children: fit(request.title, inner) }), (request.facts ?? []).flatMap((f, i) => (wrap > 1
248
- ? factRows(f, inner, wrap).map((row, j) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: row }, `fact-${i}-${j}`)))
249
- : [_jsx(Text, { wrap: "truncate-end", ...dim, children: fit(f, inner) }, `fact-${i}`)])), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.map((c, i) => {
272
+ return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [wrapRows(request.title, inner, layout.titleRows).map((row, j) => (_jsx(Text, { wrap: "truncate-end", ...titleProps, children: row }, `title-${j}`))), (request.facts ?? []).flatMap((f, i) => wrapRows(f, inner, layout.wrap).map((row, j) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: row }, `fact-${i}-${j}`)))), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.flatMap((c, i) => {
250
273
  const on = i === cursor;
251
- const main = fit(`${on ? g.cursor : ' '} ${i + 1}. ${c.label}`, inner);
252
- const room = inner - stringWidth(main);
253
- const hintText = c.hint && room > 2 ? fit(` ${c.hint}`, room) : '';
254
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...(on ? selectedProps() : {}), children: main }), hintText ? _jsx(Text, { ...dim, children: hintText }) : null] }, c.value));
274
+ const r = choiceRows(c.label, c.hint, i, inner, layout.labelRows);
275
+ const pad = ' '.repeat(choicePrefixWidth(i));
276
+ const sel = on ? selectedProps() : {};
277
+ const out = r.label.map((row, j) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...sel, children: j === 0 ? `${on ? g.cursor : ' '} ${i + 1}. ${row}` : `${pad}${row}` }), j === r.label.length - 1 && r.hint && !r.hintOwnRow ? _jsx(Text, { ...dim, children: r.hint }) : null] }, `${c.value}-${j}`)));
278
+ if (r.hintOwnRow)
279
+ out.push(_jsx(Text, { wrap: "truncate-end", ...dim, children: `${pad}${r.hint}` }, `${c.value}-hint`));
280
+ return out;
255
281
  }) }), _jsx(Text, { wrap: "truncate-end", ...dim, children: fit(hint, inner) })] }));
256
282
  }
@@ -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
+ }