@mafdet/cli 0.4.7 → 0.4.9

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/CHANGELOG.md CHANGED
@@ -6,6 +6,70 @@ The version is the contract for the JSON output shapes and the exit codes.
6
6
  Being 0.x, semver still permits a break in a minor bump; the intent is not to
7
7
  use that latitude, and the promise becomes binding at 1.0.0.
8
8
 
9
+ ## 0.4.9 - 2026-08-12
10
+
11
+ A fifth audit round: two billing and terminal-safety fixes, and two smaller
12
+ corrections.
13
+
14
+ **Incomplete usage is no longer priced as though the missing half were zero.**
15
+ A reply carrying `{}`, or only `prompt_tokens`, or only `completion_tokens`,
16
+ was charged with the absent side counted as nothing - so a run under
17
+ `--max-cost-usd` finished successfully having applied the ceiling to a partly
18
+ invented figure. A fractional count did worse: it crashed with an uncaught
19
+ error out of the pricing code. Both token counts and both prices must now be
20
+ present and valid; anything else is reported as unpriced, stops a run that
21
+ asked for a ceiling, and keeps the answer that was generated.
22
+
23
+ **Errors and diagnostics can no longer carry terminal control sequences.**
24
+ `mafdet doctor` interpolated a model's `apiSurface` straight into its table,
25
+ and a surface-mismatch error did the same into stderr. Rather than cleaning
26
+ those two strings, the cleaning now happens at the two boundaries every
27
+ command already funnels through - the shared error reporter and the diagnostic
28
+ table - so the checks and messages added later are covered too. Redirected
29
+ output and `--json` keep the bytes exactly.
30
+
31
+ **The context estimate measures UTF-8 bytes**, not UTF-16 code units, and
32
+ counts the whole request body. A Chinese conversation measured about 10%
33
+ smaller than it is on the wire, in the one estimate that must not under-count.
34
+
35
+ **Unused code is now a compile error** (`noUnusedLocals`,
36
+ `noUnusedParameters`). A dead import survived four review rounds because
37
+ nothing was looking for one; turning the check on found eight more.
38
+
39
+ ## 0.4.8 - 2026-08-12
40
+
41
+ A fourth audit round: three findings, one of which was a regression I
42
+ introduced in 0.4.6.
43
+
44
+ **Model identifiers are never rewritten.** 0.4.6 stripped terminal escape
45
+ sequences from the catalogue as it arrived, which sounded like the tidy place
46
+ to do it and was the wrong place: the identifier is what the access
47
+ intersection is computed from, so two distinct ids collapsed into one and a key
48
+ that could call only `safe<escape>` appeared to have access to `safe`. It is
49
+ also what gets sent back to the API and what `--json` reports. Identifiers are
50
+ now left exactly as served; one carrying a control character is refused
51
+ outright - it cannot be a real id - and the cleaning happens where something
52
+ draws text on a terminal, with `--json` and `--plain` keeping the bytes.
53
+
54
+ **A cost ceiling reports honestly when the response carries no usage.** The
55
+ catalogue check added in 0.4.6 covers a model with no published price; a model
56
+ that has one whose reply arrives without a usage block is only discoverable
57
+ afterwards, and that run finished `completed` with exit 0 - telling you a
58
+ ceiling had held when it had never been applied to anything. It now stops with
59
+ the ceiling's exit code, keeps the answer that was generated, and says that
60
+ usage was missing rather than that the price was.
61
+
62
+ **Context compaction measures instead of guessing.** The estimate divided the
63
+ last token count by the number of messages and scaled linearly, with a
64
+ hand-tuned allowance for the system prompt - which ignored the tool schemas,
65
+ often the largest part of a request, and assumed a fixed characters-per-token
66
+ ratio that does not hold across English, Chinese, code and JSON. It now uses
67
+ the ratio between the provider's own token count and the exact bytes that
68
+ earned it, applied to the bytes about to be sent, schemas included. It remains
69
+ an estimate: guaranteeing no request ever exceeds the window would need a
70
+ tokenizer or a server-side token count, and the documentation now says so
71
+ rather than implying otherwise.
72
+
9
73
  ## 0.4.7 - 2026-08-12
10
74
 
11
75
  0.4.6 shipped without the fix it was published for.
@@ -28,7 +92,9 @@ A third audit round. Two of its findings were things the previous round's
28
92
  notes recorded as fixed, and the pattern is worth naming: a partial fix logged
29
93
  as complete is not looked at again.
30
94
 
31
- **An interactive `mafdet agent` now records your questions.** It saved the
95
+ **An interactive `mafdet agent` now records your questions.** ⚠️ This is what
96
+ 0.4.6 was published for and it is **not in 0.4.6** - the wiring was missing and
97
+ the release went out with the old behaviour. Fixed in 0.4.7; see that entry. It saved the
32
98
  model's replies, the tool runs and the usage, and none of the prompts - so
33
99
  resuming such a session could not continue a conversation that was only half
34
100
  there. The TUI wrapped its own reader to record them and the command surface
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The official Mafdet AI command line: model calls, account queries, and a
4
4
  permissioned local agent.
5
5
 
6
- > **0.4.7.** 中文版见 [README.zh-CN.md](./README.zh-CN.md). The CLI never
6
+ > **0.4.9.** 中文版见 [README.zh-CN.md](./README.zh-CN.md). The CLI never
7
7
  > advertises a command it cannot serve, so `mafdet help` always lists exactly
8
8
  > what works.
9
9
  >
@@ -23,8 +23,8 @@ Or build it from this repository:
23
23
 
24
24
  ```bash
25
25
  pnpm --filter @mafdet/cli build
26
- cd packages/cli && npm pack # -> mafdet-cli-0.4.7.tgz
27
- npm install -g ./mafdet-cli-0.4.7.tgz
26
+ cd packages/cli && npm pack # -> mafdet-cli-0.4.9.tgz
27
+ npm install -g ./mafdet-cli-0.4.9.tgz
28
28
  ```
29
29
 
30
30
  ## The interactive session
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Mafdet AI 官方命令行:模型调用、账户查询,以及一个受权限约束的本地 Agent。
4
4
 
5
- > **0.4.7。** 英文版见 [README.md](./README.md)。
5
+ > **0.4.9。** 英文版见 [README.md](./README.md)。
6
6
 
7
7
  ## 环境要求
8
8
 
@@ -83,5 +83,46 @@ export declare function compactForBudget(history: ChatMessage[], keepRecent?: nu
83
83
  * it is the only honest figure available and it is monotone in the right
84
84
  * direction, which is what a loop needs.
85
85
  */
86
- export declare function compactUntilItFits(history: ChatMessage[], contextLength: number | undefined, lastPromptTokens: number, keepRecent?: number): CompactionResult;
86
+ /**
87
+ * U-1 · Shorten until the estimate fits, using a ratio measured from the last
88
+ * real request rather than a guess about characters per token.
89
+ *
90
+ * The previous version divided the last `prompt_tokens` by the message count
91
+ * and scaled linearly, with a hand-tuned allowance for the system prompt. That
92
+ * was wrong twice over: it ignored the tool schemas, which are re-sent every
93
+ * turn and are often larger than the conversation, and characters per token
94
+ * is not a constant across English, Chinese, code and JSON.
95
+ *
96
+ * What is measured here instead is the ratio between the provider's own token
97
+ * count and the UTF-8 size of what we sent to earn it. Applied to the bytes we are
98
+ * about to send - schemas included - it needs no assumption about language or
99
+ * encoding, because both sides of the ratio came from the same request.
100
+ *
101
+ * ⚠️ It is still an estimate, and it is not a proof. Two requests can differ
102
+ * in their token-per-byte ratio, so this cannot guarantee "never sends an
103
+ * over-window request"; that would need a tokenizer or a server-side
104
+ * token-count endpoint. What it does guarantee is that the fixed part is
105
+ * counted rather than assumed away.
106
+ */
107
+ export declare function compactUntilItFits(history: ChatMessage[], contextLength: number | undefined, lastPromptTokens: number,
108
+ /** Size of the request that produced `lastPromptTokens`, in UTF-8 bytes. */
109
+ lastRequestSize: number,
110
+ /** Size of the request as it stands now, recomputed after each removal. */
111
+ sizeNow: () => number, keepRecent?: number): CompactionResult;
112
+ /**
113
+ * V-3 · The size of a request, as UTF-8 bytes.
114
+ *
115
+ * Named `requestSize` rather than `requestBytes` for a while it was neither:
116
+ * `String.length` counts UTF-16 code units, so a Chinese conversation measured
117
+ * about 10% smaller than it is on the wire - and the estimate that uses this
118
+ * is precisely the one that must not under-count. `Buffer.byteLength` counts
119
+ * what is actually sent.
120
+ *
121
+ * ⚠️ It feeds a heuristic, not a tokenizer. The ratio it participates in is
122
+ * measured from a real request, which is the best available answer without a
123
+ * server-side token count - but it cannot prove a request fits the window.
124
+ */
125
+ export declare function requestSize(messages: readonly ChatMessage[], tools: unknown,
126
+ /** The rest of the body, so the measurement covers what is really sent. */
127
+ extra?: Record<string, unknown>): number;
87
128
  //# sourceMappingURL=context-budget.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context-budget.d.ts","sourceRoot":"","sources":["../../src/agent/context-budget.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,OAAO,CAAC;AAE/B,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,6EAA6E;AAC7E,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAKT;AAED,MAAM,WAAW,WAAW;IAC1B,uCAAuC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAsCD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,WAAW,CAkBb;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,WAAW,EAAE,EACtB,SAAS,EAAE,MAAM,GAChB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA0BpC;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,gBAAgB,CASlB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM,EACxB,UAAU,GAAE,MAA8B,GACzC,gBAAgB,CAoClB"}
1
+ {"version":3,"file":"context-budget.d.ts","sourceRoot":"","sources":["../../src/agent/context-budget.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,OAAO,CAAC;AAE/B,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,6EAA6E;AAC7E,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAKT;AAED,MAAM,WAAW,WAAW;IAC1B,uCAAuC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAsCD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,WAAW,CAkBb;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,WAAW,EAAE,EACtB,SAAS,EAAE,MAAM,GAChB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA0BpC;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,WAAW,EAAE,EACtB,UAAU,GAAE,MAA8B,GACzC,gBAAgB,CASlB;AAED;;;;;;;;;;;;;GAaG;AACH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,WAAW,EAAE,EACtB,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,EAAE,MAAM;AACxB,4EAA4E;AAC5E,eAAe,EAAE,MAAM;AACvB,2EAA2E;AAC3E,OAAO,EAAE,MAAM,MAAM,EACrB,UAAU,GAAE,MAA8B,GACzC,gBAAgB,CA8BlB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CACzB,QAAQ,EAAE,SAAS,WAAW,EAAE,EAChC,KAAK,EAAE,OAAO;AACd,2EAA2E;AAC3E,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAClC,MAAM,CAWR"}
@@ -181,19 +181,38 @@ export function compactForBudget(history, keepRecent = KEEP_RECENT_EXCHANGES) {
181
181
  * it is the only honest figure available and it is monotone in the right
182
182
  * direction, which is what a loop needs.
183
183
  */
184
- export function compactUntilItFits(history, contextLength, lastPromptTokens, keepRecent = KEEP_RECENT_EXCHANGES) {
184
+ /**
185
+ * U-1 · Shorten until the estimate fits, using a ratio measured from the last
186
+ * real request rather than a guess about characters per token.
187
+ *
188
+ * The previous version divided the last `prompt_tokens` by the message count
189
+ * and scaled linearly, with a hand-tuned allowance for the system prompt. That
190
+ * was wrong twice over: it ignored the tool schemas, which are re-sent every
191
+ * turn and are often larger than the conversation, and characters per token
192
+ * is not a constant across English, Chinese, code and JSON.
193
+ *
194
+ * What is measured here instead is the ratio between the provider's own token
195
+ * count and the UTF-8 size of what we sent to earn it. Applied to the bytes we are
196
+ * about to send - schemas included - it needs no assumption about language or
197
+ * encoding, because both sides of the ratio came from the same request.
198
+ *
199
+ * ⚠️ It is still an estimate, and it is not a proof. Two requests can differ
200
+ * in their token-per-byte ratio, so this cannot guarantee "never sends an
201
+ * over-window request"; that would need a tokenizer or a server-side
202
+ * token-count endpoint. What it does guarantee is that the fixed part is
203
+ * counted rather than assumed away.
204
+ */
205
+ export function compactUntilItFits(history, contextLength, lastPromptTokens,
206
+ /** Size of the request that produced `lastPromptTokens`, in UTF-8 bytes. */
207
+ lastRequestSize,
208
+ /** Size of the request as it stands now, recomputed after each removal. */
209
+ sizeNow, keepRecent = KEEP_RECENT_EXCHANGES) {
185
210
  const startedWith = history.length;
186
- const floor = fixedOverhead(history, lastPromptTokens);
187
- const perMessage = startedWith > 0 ? Math.max(0, lastPromptTokens - floor) / startedWith : 0;
211
+ const tokensPerByte = lastRequestSize > 0 ? lastPromptTokens / lastRequestSize : 0;
188
212
  let exchanges = 0;
189
213
  let turns = 0;
190
214
  for (;;) {
191
- // T-2 · The floor does not shrink. The system prompt and the tool schemas
192
- // are re-sent every turn whatever the history looks like, so treating the
193
- // whole measurement as proportional to the message count said a
194
- // conversation had come inside the window when only the removable part
195
- // had. Everything below the floor is not ours to drop.
196
- const estimate = floor + Math.round(perMessage * history.length);
215
+ const estimate = Math.round(tokensPerByte * sizeNow());
197
216
  if (!shouldCompact(contextLength, estimate)) {
198
217
  return {
199
218
  removed: startedWith - history.length,
@@ -217,28 +236,27 @@ export function compactUntilItFits(history, contextLength, lastPromptTokens, kee
217
236
  }
218
237
  }
219
238
  /**
220
- * T-2 · What the next request costs before any history is added.
239
+ * V-3 · The size of a request, as UTF-8 bytes.
240
+ *
241
+ * Named `requestSize` rather than `requestBytes` for a while it was neither:
242
+ * `String.length` counts UTF-16 code units, so a Chinese conversation measured
243
+ * about 10% smaller than it is on the wire - and the estimate that uses this
244
+ * is precisely the one that must not under-count. `Buffer.byteLength` counts
245
+ * what is actually sent.
221
246
  *
222
- * The system prompt is measurable; the tool schemas are not from here - the
223
- * loop knows them but this module does not, and CLI-000B found the gateway's
224
- * own reservation undercounts badly for exactly that reason. So it is
225
- * approximated from the one message we can size, with a floor that keeps the
226
- * estimate conservative: over-estimating the fixed part makes compaction try
227
- * harder, which is the direction that fails safe.
247
+ * ⚠️ It feeds a heuristic, not a tokenizer. The ratio it participates in is
248
+ * measured from a real request, which is the best available answer without a
249
+ * server-side token count - but it cannot prove a request fits the window.
228
250
  */
229
- function fixedOverhead(history, lastPromptTokens) {
230
- const system = history.find((m) => m.role === "system");
231
- if (system === undefined || lastPromptTokens <= 0)
232
- return 0;
233
- const chars = (system.content ?? "").length;
234
- // Roughly four characters per token, doubled to stand in for the tool
235
- // schemas that are sent alongside it and cannot be measured here.
236
- const approx = Math.round((chars / 4) * 2);
237
- // Bounded by the measurement, because the fixed part cannot exceed the whole
238
- // request - but NOT halved. Capping it lower was the unsafe direction: it
239
- // let the loop conclude that dropping a few messages had brought a history
240
- // inside a window the system prompt alone did not fit in. Over-estimating
241
- // makes compaction try harder and, at worst, stop with an honest message;
242
- // under-estimating sends a request that fails upstream and is billed.
243
- return Math.min(approx, lastPromptTokens);
251
+ export function requestSize(messages, tools,
252
+ /** The rest of the body, so the measurement covers what is really sent. */
253
+ extra = {}) {
254
+ try {
255
+ return Buffer.byteLength(JSON.stringify({ ...extra, messages, tools }), "utf8");
256
+ }
257
+ catch {
258
+ // Circular or unserialisable: fall back to something monotone in the
259
+ // history length so the loop still makes progress.
260
+ return messages.length;
261
+ }
244
262
  }
@@ -1 +1 @@
1
- {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { ApiError } from "../api/errors.js";
2
- import { compactUntilItFits, shouldCompact } from "./context-budget.js";
2
+ import { compactUntilItFits, requestSize, shouldCompact, } from "./context-budget.js";
3
3
  import { toolToJsonSchema, } from "./tools/types.js";
4
4
  /** Plan §7.4. */
5
5
  export const DEFAULT_LIMITS = {
@@ -48,8 +48,8 @@ export async function runAgent(options) {
48
48
  };
49
49
  /** What the provider counted for the previous request; 0 until one lands. */
50
50
  let lastPromptTokens = 0;
51
- /** R-5 · Turns whose cost the catalogue could not tell us. */
52
- let unpricedTurns = 0;
51
+ /** U-1 · UTF-8 size of the request that produced `lastPromptTokens`. */
52
+ let lastRequestSize = 0;
53
53
  // T-3 · Known before anything is sent. Waiting for the first response to
54
54
  // discover it meant a run with an explicit ceiling still made one billable
55
55
  // request that could never be counted against it.
@@ -69,15 +69,6 @@ export async function runAgent(options) {
69
69
  report.costMicroUsd >= limits.maxCostMicroUsd) {
70
70
  return stop(report, "max_cost");
71
71
  }
72
- // R-5 · An unpriceable turn counted as 0 towards the ceiling, so
73
- // `--max-cost-usd` silently stopped being a ceiling at all and the run
74
- // continued spending real money. A limit that cannot be enforced has to
75
- // stop the run, not be quietly abandoned.
76
- if (limits.maxCostMicroUsd !== undefined && unpricedTurns > 0) {
77
- return stop(report, "cost_unknown", `--max-cost-usd cannot be enforced: the catalogue has no price for ` +
78
- `${options.model}, so ${unpricedTurns} request(s) could not be counted ` +
79
- `towards the ceiling. Re-run without the limit to continue anyway.`);
80
- }
81
72
  report.turns = turn;
82
73
  options.onEvent?.({ type: "turn", turn });
83
74
  // §7.4 · Shorten before asking, using what the provider counted last time.
@@ -85,7 +76,10 @@ export async function runAgent(options) {
85
76
  // R-2 · Keep going until the estimate is inside the budget. Dropping one
86
77
  // exchange and sending anyway left a history that was still over the
87
78
  // window: the request failed upstream and was billed for the attempt.
88
- const compacted = compactUntilItFits(messages, options.pricing?.contextLength, lastPromptTokens);
79
+ const compacted = compactUntilItFits(messages, options.pricing?.contextLength, lastPromptTokens, lastRequestSize, () => requestSize(messages, toolSchemas, {
80
+ model: options.model,
81
+ tool_choice: "auto",
82
+ }));
89
83
  if (compacted.exhausted) {
90
84
  // F-008 · Nothing left that can be dropped without breaking the
91
85
  // conversation. Sending anyway means a request over the window, which
@@ -107,6 +101,12 @@ export async function runAgent(options) {
107
101
  // history, and reusing the old figure would compact again every turn.
108
102
  lastPromptTokens = 0;
109
103
  }
104
+ // U-1 · Measured for the request we are about to make, so the ratio the
105
+ // next compaction uses is the provider's own count over these exact bytes.
106
+ const sentSize = requestSize(messages, toolSchemas, {
107
+ model: options.model,
108
+ tool_choice: "auto",
109
+ });
110
110
  let response;
111
111
  try {
112
112
  response = await requestWithRateLimitHandling(options, limits, {
@@ -126,7 +126,10 @@ export async function runAgent(options) {
126
126
  return stop(report, "error", error instanceof Error ? error.message : String(error));
127
127
  }
128
128
  const usage = response.usage;
129
- lastPromptTokens = usage?.prompt_tokens ?? lastPromptTokens;
129
+ if (usage?.prompt_tokens !== undefined) {
130
+ lastPromptTokens = usage.prompt_tokens;
131
+ lastRequestSize = sentSize;
132
+ }
130
133
  report.promptTokens += usage?.prompt_tokens ?? 0;
131
134
  report.completionTokens += usage?.completion_tokens ?? 0;
132
135
  // Cost comes from returned usage, never from the reservation: CLI-000B
@@ -144,10 +147,8 @@ export async function runAgent(options) {
144
147
  costMicroUsd: turnCost,
145
148
  priced: canPrice(options.pricing, usage),
146
149
  });
147
- if (!canPrice(options.pricing, usage)) {
148
- unpricedTurns += 1;
150
+ if (!canPrice(options.pricing, usage))
149
151
  report.unpricedRequests += 1;
150
- }
151
152
  const choice = response.choices?.[0];
152
153
  const message = choice?.message;
153
154
  const calls = message?.tool_calls ?? [];
@@ -155,6 +156,17 @@ export async function runAgent(options) {
155
156
  report.finalText = message.content;
156
157
  options.onEvent?.({ type: "text", text: message.content });
157
158
  }
159
+ // U-2 · After the answer is recorded, so the user keeps what was generated
160
+ // and paid for. A model the catalogue prices whose reply carried no
161
+ // `usage` is only discoverable here, and the run used to finish
162
+ // `completed` with exit 0 - telling the user a ceiling had held when it
163
+ // had never been applied to anything.
164
+ if (limits.maxCostMicroUsd !== undefined &&
165
+ !canPrice(options.pricing, usage)) {
166
+ return stop(report, "cost_unknown", `--max-cost-usd cannot be enforced: usage was missing or incomplete ` +
167
+ `in the response for ${options.model}, so this request could not be ` +
168
+ `counted towards the ceiling.`);
169
+ }
158
170
  if (calls.length === 0) {
159
171
  // Keep the answer in the history so a session's next turn has context.
160
172
  messages.push({ role: "assistant", content: message?.content ?? "" });
@@ -305,21 +317,46 @@ export function backoffMs(retryAfterSeconds, attempt, random = Math.random) {
305
317
  // Full jitter: a fleet of CLIs must not retry in lockstep.
306
318
  return Math.round(base * (0.5 + random() * 0.5));
307
319
  }
308
- /** F-012 · Whether `priceTurn` had everything it needed to give an answer. */
320
+ /**
321
+ * V-1 · Whether this turn can be priced at all.
322
+ *
323
+ * "There is a usage object" is not the same as "there is a usage". A reply can
324
+ * carry `{}`, or only `prompt_tokens`, or a fractional or negative count - and
325
+ * each of those used to be priced as though the missing half were zero, so a
326
+ * run under `--max-cost-usd` finished successfully having applied the ceiling
327
+ * to a number that was partly invented. A fractional count did worse: it threw
328
+ * out of `priceTurn` as an uncaught RangeError.
329
+ *
330
+ * Both token counts and both prices have to be there and be usable. Anything
331
+ * short of that is unpriced, which the caller reports rather than rounds down.
332
+ */
333
+ function isTokenCount(value) {
334
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
335
+ }
336
+ /** Integer micro-USD, non-negative. Anything else is not a price. */
337
+ function isPrice(value) {
338
+ return typeof value === "string" && /^\d+$/.test(value);
339
+ }
309
340
  function canPrice(model, usage) {
310
- return (usage !== undefined &&
311
- model?.inputPricePer1mMicroUsd !== undefined &&
312
- model?.outputPricePer1mMicroUsd !== undefined);
341
+ if (usage === undefined)
342
+ return false;
343
+ if (!isTokenCount(usage.prompt_tokens))
344
+ return false;
345
+ if (!isTokenCount(usage.completion_tokens))
346
+ return false;
347
+ return (isPrice(model?.inputPricePer1mMicroUsd) &&
348
+ isPrice(model?.outputPricePer1mMicroUsd));
313
349
  }
314
350
  function priceTurn(model, usage) {
315
- if (!model?.inputPricePer1mMicroUsd ||
316
- !model.outputPricePer1mMicroUsd ||
317
- !usage) {
351
+ // V-1 · One gate, shared with the caller's reporting, so a turn can never be
352
+ // charged for and reported as unpriced - or the reverse.
353
+ if (!canPrice(model, usage))
318
354
  return 0n;
319
- }
320
- const ceilDiv = (tokens, price) => (BigInt(Math.max(0, tokens)) * BigInt(price) + 999999n) / 1000000n;
321
- return (ceilDiv(usage.prompt_tokens ?? 0, model.inputPricePer1mMicroUsd) +
322
- ceilDiv(usage.completion_tokens ?? 0, model.outputPricePer1mMicroUsd));
355
+ const prompt = usage?.prompt_tokens ?? 0;
356
+ const completion = usage?.completion_tokens ?? 0;
357
+ const ceilDiv = (tokens, price) => (BigInt(tokens) * BigInt(price) + 999999n) / 1000000n;
358
+ return (ceilDiv(prompt, model?.inputPricePer1mMicroUsd ?? "0") +
359
+ ceilDiv(completion, model?.outputPricePer1mMicroUsd ?? "0"));
323
360
  }
324
361
  function stop(report, reason, error) {
325
362
  report.stopReason = reason;
@@ -38,7 +38,7 @@ export async function runSession(options) {
38
38
  let pricing = options.pricing;
39
39
  const source = options.readLine
40
40
  ? undefined
41
- : lineSource(options.input ?? process.stdin, options.stderr);
41
+ : lineSource(options.input ?? process.stdin);
42
42
  const readLine = options.readLine ??
43
43
  (() => {
44
44
  options.stderr("\n> ");
@@ -253,7 +253,7 @@ export function compactHistory(history) {
253
253
  * a queue keeps them, and end-of-input only reports undefined once the queue
254
254
  * is actually empty.
255
255
  */
256
- function lineSource(input, write) {
256
+ function lineSource(input) {
257
257
  const rl = createInterface({
258
258
  input,
259
259
  output: process.stderr,
@@ -55,7 +55,7 @@ export async function executeCommand(argv, cwd, timeoutMs = COMMAND_TIMEOUT_MS)
55
55
  clearTimeout(timer);
56
56
  resolve({ code, signal, output: stripAnsi(output), timedOut });
57
57
  };
58
- child.on("error", (error) => finish(null, null));
58
+ child.on("error", () => finish(null, null));
59
59
  child.on("close", (code, signal) => finish(code, signal));
60
60
  });
61
61
  }
@@ -52,6 +52,21 @@ export declare function intersectModels(dataPlane: readonly DataPlaneModel[], ca
52
52
  export declare function fetchAccessibleModels(client: HttpClient): Promise<DataPlaneModel[]>;
53
53
  /** GET /models on the business API — public catalogue, no key required. */
54
54
  export declare function fetchCatalog(client: HttpClient): Promise<CatalogModel[]>;
55
+ /**
56
+ * U-3 · Is this a usable model identifier?
57
+ *
58
+ * Identity is never rewritten. Sanitising the id at ingestion - which is what
59
+ * the previous attempt did - merges two distinct ids into one, and the id is
60
+ * what the *access* intersection is computed from: a key that could call only
61
+ * `safe<OSC>` was granted `safe`. It is also what gets sent back to the API
62
+ * and what `--json` reports, and the project's own rule is that a pipe
63
+ * receives exact bytes.
64
+ *
65
+ * So an id carrying a control character is refused instead. It cannot be a
66
+ * real model id, and refusing is the fail-closed reading of a value we do not
67
+ * understand.
68
+ */
69
+ export declare function isUsableModelId(id: string): boolean;
55
70
  /**
56
71
  * Check the model serves the endpoint the command needs, before spending a
57
72
  * request on it. The gateway does answer 400 `model_api_surface_mismatch`, but
@@ -1 +1 @@
1
- {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/api/models.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,0CAA0C;AAC1C,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,aAAc,SAAQ,YAAY;IACjD,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,mDAAmD;IACnD,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gDAAgD;IAChD,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,GAAG,SAAS,GAAG,OAAO,CAQxE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,SAAS,cAAc,EAAE,EACpC,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,SAAS,CAuBX;AAED,8CAA8C;AAC9C,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,cAAc,EAAE,CAAC,CAQ3B;AAED,2EAA2E;AAC3E,wBAAsB,YAAY,CAChC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAGzB;AA0BD;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,YAAY,GAAG,SAAS,CAY1B;AAED,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,OAAO,EAAE,MAAM;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM;gBAFhB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM;CAO5B"}
1
+ {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/api/models.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,0CAA0C;AAC1C,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,aAAc,SAAQ,YAAY;IACjD,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,mDAAmD;IACnD,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gDAAgD;IAChD,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,GAAG,SAAS,GAAG,OAAO,CAUxE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,SAAS,cAAc,EAAE,EACpC,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,SAAS,CA4BX;AAED,8CAA8C;AAC9C,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,cAAc,EAAE,CAAC,CAG3B;AAED,2EAA2E;AAC3E,wBAAsB,YAAY,CAChC,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,YAAY,EAAE,CAAC,CAGzB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,YAAY,GAAG,SAAS,CAY1B;AAED,qBAAa,oBAAqB,SAAQ,KAAK;IAE3C,QAAQ,CAAC,OAAO,EAAE,MAAM;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM;gBAFhB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM;CAO5B"}
@@ -1,4 +1,3 @@
1
- import { stripAnsi } from "../agent/command-policy.js";
2
1
  /**
3
2
  * Agent admission. Every condition is something the catalogue states outright;
4
3
  * nothing is inferred from the model id, the provider or the display name —
@@ -7,6 +6,9 @@ import { stripAnsi } from "../agent/command-policy.js";
7
6
  export function isAgentEligible(model) {
8
7
  if (!model)
9
8
  return false;
9
+ // U-3 · An id we cannot make sense of never drives the agent.
10
+ if (!isUsableModelId(model.modelId))
11
+ return false;
10
12
  if (!(model.capabilities ?? []).includes("tool_use"))
11
13
  return false;
12
14
  if ((model.apiSurface ?? "CHAT_COMPLETIONS") !== "CHAT_COMPLETIONS")
@@ -34,6 +36,12 @@ export function intersectModels(dataPlane, catalog) {
34
36
  const available = [];
35
37
  const unlisted = [];
36
38
  for (const id of accessible) {
39
+ // U-3 · Fail closed on an id we cannot make sense of. It never becomes
40
+ // callable, never becomes agent-eligible, and is never matched against a
41
+ // catalogue entry - which is the mistake that let `safe<OSC>` be treated
42
+ // as `safe`.
43
+ if (!isUsableModelId(id))
44
+ continue;
37
45
  const meta = byId.get(id);
38
46
  if (!meta) {
39
47
  unlisted.push(id);
@@ -52,37 +60,30 @@ export function intersectModels(dataPlane, catalog) {
52
60
  /** GET /v1/models — the key's access list. */
53
61
  export async function fetchAccessibleModels(client) {
54
62
  const { data } = await client.getJson("/models");
55
- // T-5 · Same reason as `cleanModel`: these ids are printed as the "unlisted"
56
- // set, so they reach a terminal without ever passing through the catalogue.
57
- return (data.data ?? []).map((model) => ({
58
- ...model,
59
- id: typeof model.id === "string" ? stripAnsi(model.id) : model.id,
60
- }));
63
+ return data.data ?? [];
61
64
  }
62
65
  /** GET /models on the business API — public catalogue, no key required. */
63
66
  export async function fetchCatalog(client) {
64
67
  const { data } = await client.getJson("/models");
65
- return (data.data ?? []).map(cleanModel);
68
+ return data.data ?? [];
66
69
  }
67
70
  /**
68
- * T-5 · Strip terminal control sequences from catalogue text, once, here.
71
+ * U-3 · Is this a usable model identifier?
69
72
  *
70
- * The catalogue is remote data that ends up on a terminal in the picker, in
71
- * `models list`, in `models show`, in `doctor` and in error messages. F-010
72
- * was fixed sink by sink across two review rounds and the picker was still
73
- * missing, which is the argument for doing it where the data enters instead:
74
- * every renderer downstream, present and future, then has nothing to forget.
73
+ * Identity is never rewritten. Sanitising the id at ingestion - which is what
74
+ * the previous attempt did - merges two distinct ids into one, and the id is
75
+ * what the *access* intersection is computed from: a key that could call only
76
+ * `safe<OSC>` was granted `safe`. It is also what gets sent back to the API
77
+ * and what `--json` reports, and the project's own rule is that a pipe
78
+ * receives exact bytes.
75
79
  *
76
- * The model id is cleaned too. It is sent back to the API, so in principle
77
- * this changes what we would ask for - but an id carrying a control character
78
- * is not a model id, and declining to echo one back is right either way.
80
+ * So an id carrying a control character is refused instead. It cannot be a
81
+ * real model id, and refusing is the fail-closed reading of a value we do not
82
+ * understand.
79
83
  */
80
- function cleanModel(model) {
81
- const clean = (value) => typeof value === "string" ? stripAnsi(value) : value;
82
- return Object.fromEntries(Object.entries(model).map(([key, value]) => [
83
- key,
84
- Array.isArray(value) ? value.map(clean) : clean(value),
85
- ]));
84
+ export function isUsableModelId(id) {
85
+ // eslint-disable-next-line no-control-regex
86
+ return id.length > 0 && !/[\u0000-\u001f\u007f-\u009f]/.test(id);
86
87
  }
87
88
  /**
88
89
  * Check the model serves the endpoint the command needs, before spending a
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAInD,OAAO,EAAgB,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAY5D,MAAM,WAAW,QAAS,SAAQ,SAAS;IACzC,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,QAAQ,GACX,OAAO,CAAC,QAAQ,CAAC,CAyFnB"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAKnD,OAAO,EAAgB,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAY5D,MAAM,WAAW,QAAS,SAAQ,SAAS;IACzC,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,QAAQ,GACX,OAAO,CAAC,QAAQ,CAAC,CAyFnB"}
@@ -7,6 +7,7 @@ import { ConfigError } from "../config/config.js";
7
7
  import { ExitCode } from "../output/exit-codes.js";
8
8
  import { parseFormatFlags, renderJson, renderTable } from "../output/format.js";
9
9
  import { formatUsd } from "../output/money.js";
10
+ import { safeForTerminal } from "../output/terminal-safe.js";
10
11
  import { keyFingerprint, redact } from "../security/redact.js";
11
12
  import { buildRuntime } from "./runtime.js";
12
13
  const MINIMUM_NODE_MAJOR = 20;
@@ -141,14 +142,20 @@ function describe(error) {
141
142
  function report(checks, format, io) {
142
143
  const failed = checks.filter((check) => check.status === "fail").length;
143
144
  if (format === "json") {
145
+ // Exact values, as everywhere else that a machine reads.
144
146
  io.stdout(renderJson({ ok: failed === 0, checks }));
145
147
  }
146
148
  else {
147
149
  const mark = { ok: "ok", warn: "warn", fail: "FAIL" };
150
+ // V-2 · One place every check's detail is drawn, and several of them are
151
+ // built from remote text - a model's apiSurface, an endpoint, an upstream
152
+ // error. Cleaning here covers the checks that exist and the ones added
153
+ // later; cleaning each check's string did not.
154
+ const tty = io.isTty ?? process.stdout.isTTY === true;
148
155
  io.stdout(renderTable(checks, [
149
156
  { header: "", value: (c) => mark[c.status] },
150
- { header: "CHECK", value: (c) => c.name },
151
- { header: "DETAIL", value: (c) => c.detail },
157
+ { header: "CHECK", value: (c) => safeForTerminal(c.name, tty) },
158
+ { header: "DETAIL", value: (c) => safeForTerminal(c.detail, tty) },
152
159
  ]));
153
160
  }
154
161
  // Warnings are survivable — a missing scope or an unverified model does not
@@ -1 +1 @@
1
- {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/commands/models.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAUnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AA0BtB,wBAAsB,aAAa,CACjC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,QAAQ,CAAC,CA6BnB"}
1
+ {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/commands/models.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAWnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AA0CtB,wBAAsB,aAAa,CACjC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,QAAQ,CAAC,CA6BnB"}
@@ -2,6 +2,7 @@ import { fetchAccessibleModels, fetchCatalog, intersectModels, } from "../api/mo
2
2
  import { ExitCode } from "../output/exit-codes.js";
3
3
  import { parseFormatFlags, renderJson, renderPlain, renderRecord, renderTable, } from "../output/format.js";
4
4
  import { formatUsd } from "../output/money.js";
5
+ import { safeForTerminal } from "../output/terminal-safe.js";
5
6
  import { buildRuntime, reportError, requireApiKey, } from "./runtime.js";
6
7
  const USAGE = `Usage:
7
8
  mafdet models list [--surface <chat|embeddings|speech>] [--agent] [--json|--plain]
@@ -13,10 +14,21 @@ const SURFACES = {
13
14
  speech: "AUDIO_SPEECH",
14
15
  realtime: "REALTIME",
15
16
  };
16
- const columns = [
17
- { header: "MODEL", value: (m) => m.modelId },
18
- { header: "PROVIDER", value: (m) => m.provider ?? "-" },
19
- { header: "SURFACE", value: (m) => m.apiSurface ?? "-" },
17
+ /**
18
+ * U-3 · Terminal-safe only for the table. `--json` keeps the catalogue's
19
+ * bytes exactly as served, which is the project's stated rule for anything a
20
+ * script parses - and the identity fields are never rewritten anywhere.
21
+ */
22
+ const columns = (isTty) => [
23
+ { header: "MODEL", value: (m) => safeForTerminal(m.modelId, isTty) },
24
+ {
25
+ header: "PROVIDER",
26
+ value: (m) => safeForTerminal(m.provider ?? "-", isTty),
27
+ },
28
+ {
29
+ header: "SURFACE",
30
+ value: (m) => safeForTerminal(m.apiSurface ?? "-", isTty),
31
+ },
20
32
  {
21
33
  header: "CONTEXT",
22
34
  value: (m) => String(m.contextLength ?? "-"),
@@ -24,6 +36,10 @@ const columns = [
24
36
  },
25
37
  { header: "AGENT", value: (m) => (m.agentEligible ? "yes" : "-") },
26
38
  ];
39
+ /** Whether a terminal is on the other end of stdout. Injectable for tests. */
40
+ function isTty(io) {
41
+ return io.isTty ?? process.stdout.isTTY === true;
42
+ }
27
43
  export async function modelsCommand(argv, io) {
28
44
  const [subcommand, ...rest] = argv;
29
45
  if (subcommand !== "list" && subcommand !== "show") {
@@ -88,8 +104,10 @@ function listModels(sets, args, format, io) {
88
104
  return ExitCode.Ok;
89
105
  }
90
106
  io.stdout(format === "plain"
91
- ? renderPlain(rows, columns)
92
- : renderTable(rows, columns));
107
+ ? // `--plain` is for `cut` and `awk`, so it keeps the bytes; the table is
108
+ // for a person and gets cleaned.
109
+ renderPlain(rows, columns(false))
110
+ : renderTable(rows, columns(isTty(io))));
93
111
  if (format === "table" && sets.unlisted.length > 0) {
94
112
  io.stderr(`note: ${sets.unlisted.length} model(s) callable but missing from the catalogue: ${sets.unlisted.join(", ")}\n`);
95
113
  }
@@ -117,16 +135,19 @@ function showModel(sets, args, format, io) {
117
135
  return ExitCode.Usage;
118
136
  }
119
137
  if (format === "json") {
138
+ // U-3 · Exactly as the catalogue served it. A script parsing this needs
139
+ // the real values, and identity is never rewritten anywhere.
120
140
  io.stdout(renderJson(model));
121
141
  return ExitCode.Ok;
122
142
  }
143
+ const clean = (text) => safeForTerminal(text, isTty(io));
123
144
  io.stdout(renderRecord([
124
- ["model", model.modelId],
125
- ["name", model.displayName ?? "-"],
126
- ["provider", model.provider ?? "-"],
127
- ["surface", model.apiSurface ?? "-"],
145
+ ["model", clean(model.modelId)],
146
+ ["name", clean(model.displayName ?? "-")],
147
+ ["provider", clean(model.provider ?? "-")],
148
+ ["surface", clean(model.apiSurface ?? "-")],
128
149
  ["context", String(model.contextLength ?? "-")],
129
- ["capabilities", (model.capabilities ?? []).join(", ") || "-"],
150
+ ["capabilities", clean((model.capabilities ?? []).join(", ")) || "-"],
130
151
  ["agent eligible", model.agentEligible ? "yes" : "no"],
131
152
  [
132
153
  "input / 1M",
@@ -16,6 +16,12 @@ export interface CommandIo {
16
16
  * Defaults to `process.stdout.isTTY`; tests set it explicitly.
17
17
  */
18
18
  isTty?: boolean;
19
+ /**
20
+ * V-2 · Whether stderr is a terminal. Separate from `isTty` because
21
+ * `mafdet ... 2> log` redirects one and not the other, and the rule is about
22
+ * the destination rather than the command.
23
+ */
24
+ isErrTty?: boolean;
19
25
  }
20
26
  export interface Runtime {
21
27
  config: LoadedConfig;
@@ -31,5 +37,15 @@ export declare function requireApiKey(config: LoadedConfig): void;
31
37
  * Turn any failure into an exit code and a single stderr line. Nothing here
32
38
  * prints a raw upstream message: ApiError redacted it at construction.
33
39
  */
40
+ /**
41
+ * V-2 · The one place errors reach a person.
42
+ *
43
+ * Every command funnels through here, so this is where untrusted text in an
44
+ * error message stops being able to drive the terminal. Patching the
45
+ * individual messages was tried and missed two of them; a message is
46
+ * assembled from remote data far more often than anyone remembers.
47
+ *
48
+ * Redirected stderr keeps the bytes, the same rule as everywhere else.
49
+ */
34
50
  export declare function reportError(error: unknown, io: CommandIo): ExitCode;
35
51
  //# sourceMappingURL=runtime.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/commands/runtime.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE5C,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEnD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uDAAuD;IACvD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,YAAY,CAAC;IACrB,sCAAsC;IACtC,SAAS,EAAE,UAAU,CAAC;IACtB,yCAAyC;IACzC,YAAY,EAAE,UAAU,CAAC;CAC1B;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGjD;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAgBnD;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CASxD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,GAAG,QAAQ,CAkBnE"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/commands/runtime.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE5C,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAGnD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uDAAuD;IACvD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,YAAY,CAAC;IACrB,sCAAsC;IACtC,SAAS,EAAE,UAAU,CAAC;IACtB,yCAAyC;IACzC,YAAY,EAAE,UAAU,CAAC;CAC1B;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGjD;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAgBnD;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CASxD;AAED;;;GAGG;AACH;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,GAAG,QAAQ,CAqBnE"}
@@ -3,6 +3,7 @@ import { loadConfig } from "../config/load.js";
3
3
  import { HttpClient } from "../api/http.js";
4
4
  import { ApiError } from "../api/errors.js";
5
5
  import { ExitCode } from "../output/exit-codes.js";
6
+ import { safeForTerminal } from "../output/terminal-safe.js";
6
7
  export function userAgent(version) {
7
8
  // Identifiable, and carrying nothing about the user (plan §9.2).
8
9
  return `mafdet-cli/${version}`;
@@ -37,10 +38,21 @@ export function requireApiKey(config) {
37
38
  * Turn any failure into an exit code and a single stderr line. Nothing here
38
39
  * prints a raw upstream message: ApiError redacted it at construction.
39
40
  */
41
+ /**
42
+ * V-2 · The one place errors reach a person.
43
+ *
44
+ * Every command funnels through here, so this is where untrusted text in an
45
+ * error message stops being able to drive the terminal. Patching the
46
+ * individual messages was tried and missed two of them; a message is
47
+ * assembled from remote data far more often than anyone remembers.
48
+ *
49
+ * Redirected stderr keeps the bytes, the same rule as everywhere else.
50
+ */
40
51
  export function reportError(error, io) {
52
+ const safe = (text) => safeForTerminal(text, io.isErrTty ?? process.stderr.isTTY === true);
41
53
  if (error instanceof ApiError) {
42
54
  const id = error.requestId ? ` (request ${error.requestId})` : "";
43
- io.stderr(`mafdet: ${error.message}${id}\n`);
55
+ io.stderr(`mafdet: ${safe(error.message)}${id}\n`);
44
56
  if (error.kind === "rate_limited" &&
45
57
  error.retryAfterSeconds !== undefined) {
46
58
  io.stderr(`mafdet: retry after ${error.retryAfterSeconds}s\n`);
@@ -48,9 +60,9 @@ export function reportError(error, io) {
48
60
  return error.exitCode;
49
61
  }
50
62
  if (error instanceof ConfigError) {
51
- io.stderr(`mafdet: ${error.message}\n`);
63
+ io.stderr(`mafdet: ${safe(error.message)}\n`);
52
64
  return ExitCode.Usage;
53
65
  }
54
- io.stderr(`mafdet: ${error.message}\n`);
66
+ io.stderr(`mafdet: ${safe(error.message)}\n`);
55
67
  return ExitCode.General;
56
68
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/commands/sessions.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AA4BnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAgBtB,wBAAsB,eAAe,CACnC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,QAAQ,CAAC,CAUnB;AAoRD,eAAO,MAAM,eAAe,GAAI,IAAI,MAAM,KAAG,MAUX,CAAC"}
1
+ {"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/commands/sessions.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AA4BnD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAetB,wBAAsB,eAAe,CACnC,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,SAAS,GACZ,OAAO,CAAC,QAAQ,CAAC,CAUnB;AAoRD,eAAO,MAAM,eAAe,GAAI,IAAI,MAAM,KAAG,MAUX,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"open.d.ts","sourceRoot":"","sources":["../../src/sessions/open.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAEL,oBAAoB,EAGpB,KAAK,aAAa,EACnB,MAAM,aAAa,CAAC;AAKrB,OAAO,EAAE,aAAa,EAAe,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAE9E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,QAAQ,CAAC,sBAAsB,EAAE,SAAS,MAAM,EAAE,CAAC;CACpD;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACxD;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,WAAW,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,WAAW,EAAE,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;CAC/D,CAAC;AAEN,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,UAAU,CAqFnE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,GAC9C,MAAM,CAyBR"}
1
+ {"version":3,"file":"open.d.ts","sourceRoot":"","sources":["../../src/sessions/open.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAEL,oBAAoB,EAGpB,KAAK,aAAa,EACnB,MAAM,aAAa,CAAC;AAKrB,OAAO,EAAE,aAAa,EAAe,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAE9E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,QAAQ,CAAC,sBAAsB,EAAE,SAAS,MAAM,EAAE,CAAC;CACpD;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACxD;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,WAAW,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,WAAW,EAAE,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;CAC/D,CAAC;AAEN,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,UAAU,CAqFnE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,GAC9C,MAAM,CAyBR"}
@@ -1 +1 @@
1
- {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../../src/tui/adapters/models.ts"],"names":[],"mappings":"AAiBA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,aAAa,EACnB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAgB,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAKzE;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,QAAQ,CAAC;AAEzC,MAAM,WAAW,cAAe,SAAQ,SAAS;IAC/C,uEAAuE;IACvE,QAAQ,CAAC,IAAI,EAAE,SAAS,aAAa,EAAE,CAAC;IACxC,wDAAwD;IACxD,QAAQ,CAAC,UAAU,EAAE,SAAS,aAAa,EAAE,CAAC;CAC/C;AAED,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAC3C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAC;CACxB,GAAG,OAAO,CAAC,cAAc,CAAC,CAoB1B;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAkB1D;AASD;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,aAAa,EAAE,EAChC,KAAK,EAAE,MAAM,GACZ,SAAS,aAAa,EAAE,CAgB1B;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,qEAAqE;IACrE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE;IACxC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B,GAAG,UAAU,CA8Cb"}
1
+ {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../../src/tui/adapters/models.ts"],"names":[],"mappings":"AAiBA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,aAAa,EACnB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAgB,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAKzE;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,QAAQ,CAAC;AAEzC,MAAM,WAAW,cAAe,SAAQ,SAAS;IAC/C,uEAAuE;IACvE,QAAQ,CAAC,IAAI,EAAE,SAAS,aAAa,EAAE,CAAC;IACxC,wDAAwD;IACxD,QAAQ,CAAC,UAAU,EAAE,SAAS,aAAa,EAAE,CAAC;CAC/C;AAED,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAC3C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAC;CACxB,GAAG,OAAO,CAAC,cAAc,CAAC,CAoB1B;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAoB1D;AASD;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,aAAa,EAAE,EAChC,KAAK,EAAE,MAAM,GACZ,SAAS,aAAa,EAAE,CAgB1B;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,qEAAqE;IACrE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE;IACxC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B,GAAG,UAAU,CA8Cb"}
@@ -8,6 +8,7 @@
8
8
  import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { fetchAccessibleModels, fetchCatalog, intersectModels, } from "../../api/models.js";
11
+ import { stripAnsi } from "../../agent/command-policy.js";
11
12
  import { buildRuntime } from "../../commands/runtime.js";
12
13
  import { parseUserConfig } from "../../config/config.js";
13
14
  import { userConfigPath } from "../../config/paths.js";
@@ -38,6 +39,8 @@ export async function loadCatalogue(options) {
38
39
  }
39
40
  /** One line of facts under a model's name. Only what the catalogue states. */
40
41
  export function describeModel(model) {
42
+ // U-3 · One line drawn on a terminal from remote text. Cleaned here rather
43
+ // than at ingestion, which would change values the CLI matches and sends.
41
44
  const parts = [];
42
45
  if (model.provider !== undefined)
43
46
  parts.push(model.provider);
@@ -54,7 +57,7 @@ export function describeModel(model) {
54
57
  ? `DEPRECATED - use ${model.replacement_model_id}`
55
58
  : "DEPRECATED");
56
59
  }
57
- return parts.join(" - ");
60
+ return stripAnsi(parts.join(" - "));
58
61
  }
59
62
  function priceSummary(model) {
60
63
  const input = model.inputPricePer1mMicroUsd;
@@ -53,7 +53,7 @@ export async function promptLine(io, prompt) {
53
53
  }
54
54
  export async function runAgentSurface(options) {
55
55
  const { io, capabilities } = options;
56
- const { dim, bold } = palette(capabilities);
56
+ const { dim } = palette(capabilities);
57
57
  const cards = new ToolCardLog();
58
58
  const run = options.run ?? runSession;
59
59
  const onEvent = (event) => {
@@ -1 +1 @@
1
- {"version":3,"file":"picker.d.ts","sourceRoot":"","sources":["../../src/tui/picker.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAW7C,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,WAAW,CAAC;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,iDAAiD;IACjD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,wBAAsB,SAAS,CAC7B,EAAE,EAAE,QAAQ,EACZ,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,UAAU,CAAC,CAiHrB;AA6BD;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,oBAAoB,GACjC,MAAM,CAaR;AAED,iEAAiE;AACjE,YAAY,EAAE,GAAG,EAAE,CAAC"}
1
+ {"version":3,"file":"picker.d.ts","sourceRoot":"","sources":["../../src/tui/picker.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAErC,OAAO,EAAW,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAW7C,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,WAAW,CAAC;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,iDAAiD;IACjD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,wBAAsB,SAAS,CAC7B,EAAE,EAAE,QAAQ,EACZ,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,UAAU,CAAC,CAsHrB;AA6BD;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,oBAAoB,GACjC,MAAM,CAaR;AAED,iEAAiE;AACjE,YAAY,EAAE,GAAG,EAAE,CAAC"}
@@ -5,6 +5,7 @@
5
5
  // difference between "you cannot use this" and "the agent cannot drive this"
6
6
  // stays visible instead of looking like a missing model.
7
7
  import { describeModel, filterModels } from "./adapters/models.js";
8
+ import { stripAnsi } from "../agent/command-policy.js";
8
9
  import { palette } from "./theme.js";
9
10
  const ESC = String.fromCharCode(27);
10
11
  const CSI = `${ESC}[`;
@@ -40,8 +41,11 @@ export async function pickModel(io, options) {
40
41
  else {
41
42
  for (const { model, i } of windowOf(matches, index)) {
42
43
  const selected = i === index;
44
+ // U-3 · Sanitised here, at the terminal, and not at ingestion: the
45
+ // catalogue's values are identity and are matched, sent and reported
46
+ // verbatim. Only what is drawn gets cleaned.
43
47
  const name = model.displayName ?? model.modelId;
44
- const head = `${model.modelId}${name !== model.modelId ? ` (${name})` : ""}`;
48
+ const head = stripAnsi(`${model.modelId}${name !== model.modelId ? ` (${name})` : ""}`);
45
49
  lines.push(selected ? accent(` > ${head}`) : ` ${head}`);
46
50
  lines.push(dim(` ${describeModel(model)}`));
47
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mafdet/cli",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Mafdet AI command line — model calls, account queries, and a permissioned local agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",