@cspeach/cli 1.1.18 → 1.1.20

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 (252) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/anthropic-provider.js +30 -10
  3. package/dist/agent/cache-keepalive.js +162 -0
  4. package/dist/agent/cold-prune.js +116 -0
  5. package/dist/agent/loop.js +804 -159
  6. package/dist/agent/provider-shape.js +263 -0
  7. package/dist/agent/providers/ai-hub-provider.js +17 -2
  8. package/dist/agent/providers/byok-provider.js +33 -3
  9. package/dist/agent/providers/local-provider.js +12 -2
  10. package/dist/agent/repair-partial.js +125 -7
  11. package/dist/agent/summarise-via-provider.js +6 -1
  12. package/dist/agent/system-prompt.js +38 -0
  13. package/dist/agent/tool-dispatch.js +66 -21
  14. package/dist/agent/tool-loading-pin.js +100 -0
  15. package/dist/agent/turn-error-ux.js +77 -26
  16. package/dist/agent/turn-stream.js +25 -3
  17. package/dist/approvals/adt-type.js +27 -0
  18. package/dist/approvals/advisory-prompt.js +48 -0
  19. package/dist/approvals/advisory-render.js +25 -11
  20. package/dist/approvals/approval-prompt.js +74 -24
  21. package/dist/approvals/jwt.js +2 -1
  22. package/dist/approvals/render.js +64 -36
  23. package/dist/approvals/risk-floor.js +53 -2
  24. package/dist/auth/api-key.js +30 -16
  25. package/dist/auth/keychain.js +0 -0
  26. package/dist/auth/me.js +25 -7
  27. package/dist/cli.js +36 -6
  28. package/dist/commands/auto-compact.js +33 -16
  29. package/dist/commands/compact.js +49 -11
  30. package/dist/commands/config-set.js +16 -3
  31. package/dist/commands/config-show.js +18 -5
  32. package/dist/commands/cost.js +54 -35
  33. package/dist/commands/help.js +88 -50
  34. package/dist/commands/login.js +28 -19
  35. package/dist/commands/logout.js +7 -3
  36. package/dist/commands/plan-audit.js +1 -0
  37. package/dist/commands/plan-chain.js +71 -77
  38. package/dist/commands/plan-continue.js +1 -0
  39. package/dist/commands/plan-resume.js +20 -12
  40. package/dist/commands/whoami.js +23 -9
  41. package/dist/config/loader.js +70 -5
  42. package/dist/cost/cost-log.js +62 -2
  43. package/dist/cost/pricing.js +11 -6
  44. package/dist/doctor/checks/forge-rules.js +13 -3
  45. package/dist/doctor/checks/keychain.js +6 -4
  46. package/dist/doctor/run.js +101 -20
  47. package/dist/index.js +5 -2
  48. package/dist/lib/graceful-exit.js +56 -0
  49. package/dist/lib/piped-prompt.js +65 -0
  50. package/dist/lib/spill-labels.js +13 -0
  51. package/dist/lock-contention.js +2 -2
  52. package/dist/models/resolve.js +93 -2
  53. package/dist/models/server-config.js +158 -3
  54. package/dist/one-shot.js +150 -45
  55. package/dist/projects/answer-blockers.js +6 -1
  56. package/dist/projects/handover-md.js +15 -14
  57. package/dist/projects/image-attachments.js +15 -2
  58. package/dist/projects/plan-run.js +97 -54
  59. package/dist/projects/promote-command.js +11 -2
  60. package/dist/projects/save-command.js +3 -2
  61. package/dist/projects/status.js +2 -1
  62. package/dist/projects/yes-no.js +12 -0
  63. package/dist/renderer/abap-inline.js +18 -14
  64. package/dist/renderer/answer-trim.js +69 -0
  65. package/dist/renderer/banners.js +8 -8
  66. package/dist/renderer/brand-settled.js +19 -0
  67. package/dist/renderer/change-card.js +156 -0
  68. package/dist/renderer/checklist-format.js +130 -0
  69. package/dist/renderer/color-mode.js +45 -0
  70. package/dist/renderer/error-detail.js +104 -0
  71. package/dist/renderer/fence-state.js +46 -0
  72. package/dist/renderer/footer-line.js +100 -0
  73. package/dist/renderer/glyphs.js +25 -0
  74. package/dist/renderer/goodbye.js +38 -0
  75. package/dist/renderer/legacy-palette.js +41 -0
  76. package/dist/renderer/look.js +30 -0
  77. package/dist/renderer/markdown.js +299 -92
  78. package/dist/renderer/notice-log.js +72 -0
  79. package/dist/renderer/notice-shape.js +48 -0
  80. package/dist/renderer/notices.js +40 -8
  81. package/dist/renderer/panel-rows.js +16 -0
  82. package/dist/renderer/pipeline.js +66 -0
  83. package/dist/renderer/progress-chatter.js +23 -17
  84. package/dist/renderer/rendering-mode.js +34 -0
  85. package/dist/renderer/routing-line.js +14 -0
  86. package/dist/renderer/sanitize.js +12 -0
  87. package/dist/renderer/severity.js +2 -7
  88. package/dist/renderer/startup-lines.js +154 -0
  89. package/dist/renderer/status-footer.js +52 -34
  90. package/dist/renderer/steering-echo.js +41 -0
  91. package/dist/renderer/syntax.js +31 -12
  92. package/dist/renderer/tables.js +5 -1
  93. package/dist/renderer/theme.js +44 -0
  94. package/dist/renderer/thinking-heartbeat.js +33 -2
  95. package/dist/renderer/todo-block.js +10 -49
  96. package/dist/renderer/tool-labels.js +269 -0
  97. package/dist/renderer/tool-widget.js +245 -51
  98. package/dist/renderer/trace.js +42 -0
  99. package/dist/renderer/transcript-flow.js +132 -0
  100. package/dist/renderer/tty.js +34 -1
  101. package/dist/renderer/ui-width.js +55 -0
  102. package/dist/renderer/verify-chain.js +4 -2
  103. package/dist/renderer/widget-fallback.js +58 -65
  104. package/dist/repl/bracketed-paste.js +7 -1
  105. package/dist/repl/builtin-commands.js +19 -8
  106. package/dist/repl/credential-handover-gate.js +28 -0
  107. package/dist/repl/current-transport.js +13 -0
  108. package/dist/repl/early-line-buffer.js +5 -2
  109. package/dist/repl/file-picker.js +54 -12
  110. package/dist/repl/ink-stdin-guard.js +66 -0
  111. package/dist/repl/inquirer-guard.js +59 -16
  112. package/dist/repl/inquirer-theme.js +27 -33
  113. package/dist/repl/paste-marker.js +19 -0
  114. package/dist/repl/plan-turn-end.js +20 -0
  115. package/dist/repl/post-turn-status.js +43 -11
  116. package/dist/repl/reroute-turn.js +21 -0
  117. package/dist/repl/reset-tty-stdin.js +44 -0
  118. package/dist/repl/restore-guard.js +22 -0
  119. package/dist/repl/resume-standalone.js +63 -0
  120. package/dist/repl/rule8-detector.js +11 -3
  121. package/dist/repl/safety-confirm.js +161 -93
  122. package/dist/repl/session-spend-line.js +8 -4
  123. package/dist/repl/themed-prompts.js +19 -0
  124. package/dist/repl/ui-look-command.js +75 -0
  125. package/dist/repl/update-method-preview-hook.js +48 -5
  126. package/dist/repl.js +863 -339
  127. package/dist/rewind/cli.js +3 -2
  128. package/dist/rewind/format.js +14 -9
  129. package/dist/rewind/restore.js +11 -0
  130. package/dist/router/classifier.js +47 -4
  131. package/dist/router/intent-extractor.js +24 -8
  132. package/dist/router/piped-routing.js +23 -0
  133. package/dist/router/resume-routing.js +16 -0
  134. package/dist/router/routing-failure.js +57 -0
  135. package/dist/sap/first-run-choice.js +1 -1
  136. package/dist/sap/onboarding.js +3 -2
  137. package/dist/sap/standalone-onboarding.js +1 -1
  138. package/dist/sap/system-info.js +4 -2
  139. package/dist/sap/unreachable.js +28 -0
  140. package/dist/sap-errors/clean-error-text.js +182 -0
  141. package/dist/session/interrupt-reason.js +39 -0
  142. package/dist/session/recap.js +36 -24
  143. package/dist/session/repin-model.js +18 -0
  144. package/dist/session/resume.js +104 -36
  145. package/dist/session/store.js +84 -28
  146. package/dist/session/user-prompt.js +21 -0
  147. package/dist/skill-catalog.js +7 -0
  148. package/dist/skills/bundled-skills.js +76 -69
  149. package/dist/skills/preamble.js +75 -0
  150. package/dist/skills/source-manifest.js +11 -1
  151. package/dist/standards/standards-init.js +1 -1
  152. package/dist/test-helpers/answer-any.js +46 -0
  153. package/dist/tools/approval.js +27 -11
  154. package/dist/tools/ask-question.js +47 -5
  155. package/dist/tools/filesystem/file-read.js +11 -1
  156. package/dist/tools/filesystem/file-write.js +19 -2
  157. package/dist/tools/fiori/fe-extend.js +16 -1
  158. package/dist/tools/fiori/fe-scaffold.js +105 -3
  159. package/dist/tools/fiori/html-escapes.js +28 -0
  160. package/dist/tools/fiori/metadata/audit.js +605 -0
  161. package/dist/tools/fiori/metadata/references.js +104 -0
  162. package/dist/tools/fiori/metadata/types.js +8 -0
  163. package/dist/tools/fiori/preview/app-guard.js +215 -0
  164. package/dist/tools/fiori/preview/env.js +193 -0
  165. package/dist/tools/fiori/preview/readiness.js +127 -0
  166. package/dist/tools/fiori/preview/registry.js +412 -0
  167. package/dist/tools/fiori/preview/start.js +469 -0
  168. package/dist/tools/fiori/samples/loader.js +20 -5
  169. package/dist/tools/fiori/scaffold.js +18 -6
  170. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  171. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  172. package/dist/tools/fiori/smoke/assertions.js +161 -24
  173. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  174. package/dist/tools/fiori/smoke/batch.js +273 -0
  175. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  176. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  177. package/dist/tools/fiori/smoke/driver.js +171 -4
  178. package/dist/tools/fiori/smoke/evidence.js +35 -0
  179. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  180. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  181. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  182. package/dist/tools/fiori/tools.js +651 -7
  183. package/dist/tools/fiori/ui5-version.js +16 -0
  184. package/dist/tools/index.js +8 -0
  185. package/dist/tools/local-build.js +6 -0
  186. package/dist/tools/result-spill.js +238 -0
  187. package/dist/tools/sap-read.js +74 -15
  188. package/dist/tools/sap-write.js +122 -10
  189. package/dist/tools/shell/shell_exec.js +39 -12
  190. package/dist/tools/subagent/adt-serial.js +33 -0
  191. package/dist/tools/subagent/agent_run.js +2 -0
  192. package/dist/tools/subagent/read_agent.js +178 -0
  193. package/dist/tools/subagent/reader-prompt.js +48 -0
  194. package/dist/tools/syntax-state.js +67 -0
  195. package/dist/tools/todo.js +26 -19
  196. package/dist/tools/tool-loading.js +255 -0
  197. package/dist/tools/tool-output-read.js +117 -0
  198. package/dist/tools/transport-fit.js +462 -0
  199. package/dist/tools/transport-resolution.js +3 -1
  200. package/dist/tools/transport.js +39 -8
  201. package/dist/tools/verify.js +184 -8
  202. package/dist/ui/app.js +284 -146
  203. package/dist/ui/approval-modal.js +41 -15
  204. package/dist/ui/ask-answer-text.js +17 -0
  205. package/dist/ui/body.js +25 -6
  206. package/dist/ui/card-slot.js +98 -0
  207. package/dist/ui/checklist.js +9 -0
  208. package/dist/ui/coaching-picker-classic.js +8 -5
  209. package/dist/ui/command-palette.js +6 -4
  210. package/dist/ui/confirm-request.js +18 -0
  211. package/dist/ui/context-grid.js +2 -1
  212. package/dist/ui/file-palette.js +6 -4
  213. package/dist/ui/files-card-emitter.js +19 -0
  214. package/dist/ui/footer-line.js +16 -0
  215. package/dist/ui/footer.js +105 -104
  216. package/dist/ui/input-wrap.js +122 -0
  217. package/dist/ui/interrupt.js +103 -0
  218. package/dist/ui/key-burst.js +117 -0
  219. package/dist/ui/line-resolution.js +4 -3
  220. package/dist/ui/live-area.js +71 -0
  221. package/dist/ui/login-banner.js +54 -43
  222. package/dist/ui/rewind-panel.js +76 -24
  223. package/dist/ui/role-props.js +47 -0
  224. package/dist/ui/sap-state-store.js +2 -1
  225. package/dist/ui/session-timeline.js +49 -3
  226. package/dist/ui/skill-picker.js +5 -47
  227. package/dist/ui/text-input.js +128 -24
  228. package/dist/ui/todo-emitter.js +19 -0
  229. package/dist/ui/turn-status-emitter.js +65 -2
  230. package/dist/ui/turn-status.js +16 -19
  231. package/dist/ui/use-card-request.js +44 -0
  232. package/dist/ui/widgets/ask-card.js +521 -0
  233. package/dist/ui/widgets/bar-chart.js +11 -5
  234. package/dist/ui/widgets/coaching-picker.js +50 -55
  235. package/dist/ui/widgets/confirm-card.js +193 -0
  236. package/dist/ui/widgets/dep-graph.js +3 -2
  237. package/dist/ui/widgets/diff-viewer.js +6 -5
  238. package/dist/ui/widgets/files-card.js +97 -0
  239. package/dist/ui/widgets/question-card.js +2 -1
  240. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  241. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  242. package/dist/ui/widgets/stack-frames.js +2 -1
  243. package/dist/upgrade-check.js +39 -8
  244. package/package.json +12 -2
  245. package/test-harness/fe-draft/app.ts +188 -0
  246. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  247. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  248. package/test-harness/fe-draft/routes.ts +224 -0
  249. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  250. package/test-harness/fe-draft/server.ts +225 -0
  251. package/test-harness/fe-draft/variants.ts +196 -0
  252. package/vitest.smoke-e2e.config.ts +20 -0
@@ -15,6 +15,29 @@ import fs from 'node:fs/promises';
15
15
  import path from 'node:path';
16
16
  import { sessionsDir } from '../config/paths.js';
17
17
  import { computeCost } from './pricing.js';
18
+ /**
19
+ * Task 7 — summarise `message_start.message.input_transformations` (probe P4
20
+ * shape: `[{ type: 'thinking_dropped', path, reason }]`). Counts the
21
+ * `thinking_dropped` entries and lists each distinct reason once ('unknown'
22
+ * when an entry has none). Returns undefined for absent, empty or malformed
23
+ * input, and when nothing was dropped — so the cost-line key stays absent.
24
+ */
25
+ export function summariseInputTransformations(raw) {
26
+ if (!Array.isArray(raw))
27
+ return undefined;
28
+ let dropped = 0;
29
+ const reasons = [];
30
+ for (const t of raw) {
31
+ if (!t || typeof t !== 'object' || t.type !== 'thinking_dropped')
32
+ continue;
33
+ dropped += 1;
34
+ const r = t.reason;
35
+ const reason = typeof r === 'string' && r ? r : 'unknown';
36
+ if (!reasons.includes(reason))
37
+ reasons.push(reason);
38
+ }
39
+ return dropped > 0 ? { dropped, reasons } : undefined;
40
+ }
18
41
  /**
19
42
  * Path to a session's cost log file. Co-located with the session JSON so
20
43
  * deletion cleans up both atomically.
@@ -39,6 +62,20 @@ export function buildEntry(opts) {
39
62
  entry.credits_charged = opts.credits.charged;
40
63
  entry.balance_credits = opts.credits.balance;
41
64
  }
65
+ if (opts.served_model !== undefined)
66
+ entry.served_model = opts.served_model;
67
+ if (opts.call_index !== undefined)
68
+ entry.call_index = opts.call_index;
69
+ if (opts.kind !== undefined)
70
+ entry.kind = opts.kind;
71
+ if (opts.refusal_category !== undefined)
72
+ entry.refusal_category = opts.refusal_category;
73
+ if (opts.input_transformations !== undefined)
74
+ entry.input_transformations = opts.input_transformations;
75
+ if (opts.reader && opts.reader.calls > 0) {
76
+ entry.reader_cost_usd = Math.round(opts.reader.cost * 10000) / 10000;
77
+ entry.reader_calls = opts.reader.calls;
78
+ }
42
79
  return entry;
43
80
  }
44
81
  /**
@@ -90,7 +127,22 @@ export function summarise(entries) {
90
127
  const totals = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
91
128
  let total = 0;
92
129
  const perModel = new Map();
130
+ const readerByTurn = new Map();
131
+ let lines = 0;
93
132
  for (const e of entries) {
133
+ if (typeof e.reader_calls === 'number' && e.reader_calls > 0) {
134
+ const prev = readerByTurn.get(e.turn);
135
+ if (!prev || e.reader_calls > prev.calls || (e.reader_cost_usd ?? 0) > prev.cost) {
136
+ readerByTurn.set(e.turn, {
137
+ cost: Math.max(prev?.cost ?? 0, e.reader_cost_usd ?? 0),
138
+ calls: Math.max(prev?.calls ?? 0, e.reader_calls),
139
+ });
140
+ }
141
+ }
142
+ // Fix M3 — a zero-token reader rollup line (interrupted turn) is not a model call.
143
+ if (e.kind === 'reader_rollup')
144
+ continue;
145
+ lines += 1;
94
146
  total += e.cost;
95
147
  totals.input += e.tokens.input;
96
148
  totals.output += e.tokens.output;
@@ -109,10 +161,18 @@ export function summarise(entries) {
109
161
  slot.tokens.cacheCreate += e.tokens.cacheCreate;
110
162
  perModel.set(e.model, slot);
111
163
  }
164
+ let readerCost = 0;
165
+ let readerCalls = 0;
166
+ for (const r of readerByTurn.values()) {
167
+ readerCost += r.cost;
168
+ readerCalls += r.calls;
169
+ }
112
170
  return {
113
- totalCost: Math.round(total * 10000) / 10000,
171
+ totalCost: Math.round((total + readerCost) * 10000) / 10000,
114
172
  totalTokens: totals,
115
- turns: entries.length,
173
+ turns: lines,
116
174
  byModel: [...perModel.entries()].map(([model, v]) => ({ model, ...v })),
175
+ readerCost: Math.round(readerCost * 10000) / 10000,
176
+ readerCalls,
117
177
  };
118
178
  }
@@ -11,7 +11,9 @@
11
11
  * BOTH this file AND the proxy's version. Verify on the public pricing page:
12
12
  * https://www.anthropic.com/pricing
13
13
  *
14
- * Last verified 2026-06-07. Same as proxy.
14
+ * Last verified 2026-09-27. Same as proxy. The claude-opus-5-5 row comes from
15
+ * the Opus 5.5 section of the model migration guide. The two tables are pinned
16
+ * in sync by src/cost/__tests__/pricing-drift-pin.test.ts.
15
17
  *
16
18
  * 2026-06-07 SYNC: e294cff switched the default model to claude-opus-4-8 AND
17
19
  * applied the 3x Opus correction, but only to the proxy copy — this CLI copy
@@ -22,8 +24,12 @@
22
24
  * Unknown models → returns null + records the model name to a process-level
23
25
  * Set so we surface them as an obvious warning rather than silently zeroing.
24
26
  */
27
+ import { recordFullNotice } from '../renderer/notice-log.js';
25
28
  import { getServerModelConfig } from '../models/server-config.js';
26
29
  const PRICING = {
30
+ // Claude Opus 5.5 (2026-09-27, migration guide) — $4/$20, cache read $0.20
31
+ // (0.05x input, not 10%), 5-minute cache write $5 (1.25x input).
32
+ 'claude-opus-5-5': { inputPer1M: 4.00, outputPer1M: 20.00, cacheReadPer1M: 0.20, cacheCreatePer1M: 5.00 },
27
33
  // Claude 5 family (2026-09-15) — Opus 5 keeps the Opus 4.5+ rate ($5/$25);
28
34
  // Sonnet 5 is $2/$10. Cache read = 10% of input, cache write = 1.25x input.
29
35
  'claude-opus-5': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
@@ -90,11 +96,10 @@ export function getRate(model) {
90
96
  return PRICING[stripDate];
91
97
  if (!seenUnknownModels.has(normalized)) {
92
98
  seenUnknownModels.add(normalized);
93
- // Best-effort warn; don't crash if the console is unavailable.
94
- try {
95
- console.warn(JSON.stringify({ msg: 'pricing_unknown_model', model: normalized }));
96
- }
97
- catch { /* noop */ }
99
+ // Diagnostics never reach the user's screen (fix round 3, item 1): a raw
100
+ // console.warn bypassed the Ink/classic writers and printed JSON into the
101
+ // chat, spinner, goodbye and one-shot stderr. Log file only.
102
+ recordFullNotice('debug', JSON.stringify({ msg: 'pricing_unknown_model', model: normalized }));
98
103
  }
99
104
  return null;
100
105
  }
@@ -19,6 +19,7 @@ import { loadConfig } from '../../config/loader.js';
19
19
  import { getConnection } from '../../sap/connection-manager.js';
20
20
  import { AdtClient, snapshots } from '@cspeach/sap-client';
21
21
  import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
22
+ import { verifySyntax } from '../../tools/verify.js';
22
23
  const PROBE_NAME = 'Z_CSPEACH_DOCTOR_PROBE';
23
24
  const PROBE_PACKAGE = '$TMP';
24
25
  const PROBE_SOURCE = `REPORT ${PROBE_NAME}.\nWRITE / 'cspeach doctor probe — safe to delete'.`;
@@ -84,12 +85,21 @@ export async function checkForgeRules() {
84
85
  };
85
86
  }
86
87
  // Step 4: Verify Rule 10 fired — syntax was checked. Re-run syntaxCheck and confirm clean.
87
- const syntax = await adt.syntaxCheck('PROG', PROBE_NAME);
88
- if (syntax.hasErrors) {
88
+ // Re-review item 2 — through verifySyntax (fail closed): a check that did
89
+ // not run is ok:false with its reason, never a throw out of collectChecks.
90
+ const syntax = await verifySyntax(adt, PROBE_NAME, 'PROG');
91
+ if (syntax.didNotRun !== undefined) {
89
92
  return {
90
93
  name: 'forge-rules',
91
94
  ok: false,
92
- message: `Rule 10: syntax check returned errors on probe: ${syntax.messages[0]?.text ?? 'unknown'}`,
95
+ message: `Rule 10: syntax check did not run: ${syntax.didNotRun.replace(/^Syntax check did not run:?\s*/i, '')}`,
96
+ };
97
+ }
98
+ if (!syntax.ok) {
99
+ return {
100
+ name: 'forge-rules',
101
+ ok: false,
102
+ message: `Rule 10: syntax check returned errors on probe: ${syntax.errors[0] ?? 'unknown'}`,
93
103
  };
94
104
  }
95
105
  return {
@@ -1,10 +1,12 @@
1
- import keychain from '../../auth/keychain.js';
1
+ import keychain, { newProbeAccount } from '../../auth/keychain.js';
2
2
  export async function checkKeychain() {
3
3
  try {
4
+ // Round 4 item 7 — per-process probe account (see newProbeAccount).
5
+ const account = newProbeAccount();
4
6
  const probe = `probe-${Date.now()}`;
5
- await keychain.setPassword('cspeach', 'probe', probe);
6
- const ok = (await keychain.getPassword('cspeach', 'probe')) === probe;
7
- await keychain.deletePassword('cspeach', 'probe');
7
+ await keychain.setPassword('cspeach', account, probe);
8
+ const ok = (await keychain.getPassword('cspeach', account)) === probe;
9
+ await keychain.deletePassword('cspeach', account);
8
10
  if (ok)
9
11
  return { name: 'keychain', ok: true, message: 'OS keychain working' };
10
12
  return { name: 'keychain', ok: false, message: 'probe write/read mismatch', hint: 'Per-session fallback mode will be used' };
@@ -14,8 +14,93 @@ import { checkCredits } from './checks/credits.js';
14
14
  import { checkForgeRules } from './checks/forge-rules.js';
15
15
  import { checkSystemRoles } from './checks/system-roles.js';
16
16
  import { checkStandards } from './checks/standards.js';
17
- export async function runDoctor() {
18
- console.log(chalk.cyan.bold('\nCSPeach — doctor\n'));
17
+ import { paint } from '../renderer/theme.js';
18
+ import { getGlyphs } from '../renderer/glyphs.js';
19
+ import { isNoColor } from '../renderer/color-mode.js';
20
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
21
+ import { CLI_VERSION } from '../lib/version.js';
22
+ const NAME_WIDTH = 20;
23
+ /** Check messages can carry server text (HTTP bodies, SAP errors): one line, no control characters. */
24
+ const oneLine = (s) => sanitizeControlChars(s).replace(/\s*\r?\n\s*/g, ' ').trim();
25
+ /** Human name of the terminal CSPeach is running in (for the doctor title). */
26
+ export function terminalName(env = process.env, platform = process.platform) {
27
+ if (env['WT_SESSION'])
28
+ return 'Windows Terminal';
29
+ const tp = env['TERM_PROGRAM'];
30
+ if (tp === 'vscode')
31
+ return 'VS Code terminal';
32
+ if (tp === 'iTerm.app')
33
+ return 'iTerm2';
34
+ if (tp === 'Apple_Terminal')
35
+ return 'Terminal.app';
36
+ if (tp)
37
+ return oneLine(tp);
38
+ if (env['TERM'])
39
+ return oneLine(env['TERM']);
40
+ return platform === 'win32' ? 'Windows console' : 'unknown terminal';
41
+ }
42
+ export function colourLevelName(level, noColor = isNoColor()) {
43
+ if (noColor)
44
+ return 'no colour (NO_COLOR)';
45
+ if (level >= 3)
46
+ return 'truecolor';
47
+ if (level === 2)
48
+ return '256 colours';
49
+ if (level === 1)
50
+ return '16 colours';
51
+ return 'no colour';
52
+ }
53
+ /** 1 when any check failed; a skipped check (C.2 permission skip) is not a failure. */
54
+ export function doctorExitCode(checks) {
55
+ return checks.some((c) => !c.ok && !c.skipped) ? 1 : 0;
56
+ }
57
+ export function formatDoctorTitle(meta) {
58
+ return ['', `${chalk.bold('CSPeach doctor')} ${paint('dim', `${meta.version} · ${meta.terminal} · ${meta.colour}`)}`, ''];
59
+ }
60
+ export function formatDoctorRows(checks) {
61
+ const g = getGlyphs();
62
+ const lines = [];
63
+ let passed = 0;
64
+ let warnings = 0;
65
+ let failed = 0;
66
+ for (const c of checks) {
67
+ let glyph;
68
+ if (c.skipped) {
69
+ glyph = paint('warn', g.attention);
70
+ warnings++;
71
+ }
72
+ else if (c.ok) {
73
+ glyph = paint('ok', g.done);
74
+ passed++;
75
+ }
76
+ else {
77
+ glyph = paint('bad', g.failed);
78
+ failed++;
79
+ }
80
+ lines.push(`${glyph} ${oneLine(c.name).padEnd(NAME_WIDTH)} ${paint('dim', oneLine(c.message))}`);
81
+ if (c.hint && oneLine(c.hint) !== '')
82
+ lines.push(` ${paint('dim', 'Next:')} ${oneLine(c.hint)}`);
83
+ }
84
+ const summary = `${passed} of ${checks.length} checks passed · ${warnings} warning${warnings === 1 ? '' : 's'} · ${failed} failed`;
85
+ lines.push('', paint('dim', summary), '');
86
+ return lines;
87
+ }
88
+ /**
89
+ * Re-review 2 item 3 — the forge-rules probe writes to SAP (create, lock,
90
+ * write, snapshot list, delete); any throw there (lock refused, snapshot
91
+ * store unreadable, connection drop) is a failed row with a readable one-line
92
+ * message, never a crashed doctor run.
93
+ */
94
+ async function safeForgeRules() {
95
+ try {
96
+ return await checkForgeRules();
97
+ }
98
+ catch (err) {
99
+ const msg = String(err?.message ?? err).replace(/\s+/g, ' ').trim().slice(0, 160);
100
+ return { name: 'forge-rules', ok: false, message: `Rule 7/10 probe failed: ${msg}` };
101
+ }
102
+ }
103
+ export async function collectChecks() {
19
104
  const cfg = await loadConfig();
20
105
  const checks = [];
21
106
  checks.push(await checkProxy());
@@ -30,23 +115,19 @@ export async function runDoctor() {
30
115
  checks.push(await checkCert());
31
116
  checks.push(await checkSkill());
32
117
  checks.push(await checkKeychainFallback());
33
- checks.push(await checkForgeRules());
118
+ checks.push(await safeForgeRules());
34
119
  checks.push(await checkStandards());
35
- let failed = 0;
36
- for (const c of checks) {
37
- const icon = c.skipped
38
- ? chalk.yellow('- ')
39
- : c.ok
40
- ? chalk.green('OK ')
41
- : chalk.red('X ');
42
- console.log(` ${icon} ${c.name.padEnd(22)} ${c.message}`);
43
- if (c.hint)
44
- console.log(chalk.gray(` -> ${c.hint}`));
45
- if (!c.ok && !c.skipped)
46
- failed++;
47
- }
48
- console.log(failed === 0
49
- ? chalk.green('\nAll checks passed.\n')
50
- : chalk.yellow(`\n${failed} check(s) failed.\n`));
51
- return failed === 0 ? 0 : 1;
120
+ return checks;
121
+ }
122
+ export async function runDoctor(deps = {}) {
123
+ const print = deps.print ?? ((l) => console.log(l));
124
+ const meta = deps.meta ?? { version: CLI_VERSION, terminal: terminalName(), colour: colourLevelName(chalk.level) };
125
+ // Title first: the checks take seconds (network), and the user should see
126
+ // that doctor is running.
127
+ for (const l of formatDoctorTitle(meta))
128
+ print(l);
129
+ const checks = await (deps.collect ?? collectChecks)();
130
+ for (const l of formatDoctorRows(checks))
131
+ print(l);
132
+ return doctorExitCode(checks);
52
133
  }
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from './cli.js';
3
- main(process.argv).then((code) => process.exit(code), (err) => {
3
+ import { gracefulExit } from './lib/graceful-exit.js';
4
+ // Round 7 item 5 — close fetch's sockets before exiting (Windows libuv
5
+ // assertion on process.exit with live undici handles, nodejs/node#56645).
6
+ main(process.argv).then((code) => gracefulExit(code), (err) => {
4
7
  console.error('cspeach: unhandled error:', err);
5
- process.exit(1);
8
+ return gracefulExit(1);
6
9
  });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Round 7 item 5 — exit without tearing live network handles down under Node.
3
+ *
4
+ * On Windows, process.exit() while fetch (Node's built-in undici) still holds
5
+ * sockets can abort with the libuv assertion
6
+ * `!(handle->flags & UV_HANDLE_CLOSING), src\win\async.c line 76`
7
+ * (known Node bug: nodejs/node#56645, #64322). The matrix saw it once after a
8
+ * routing cancel + /exit. We close fetch's global dispatcher first and give the
9
+ * loop a short, capped chance to settle, then exit as before.
10
+ *
11
+ * Review r7 M-1 — drain with close() (in-flight requests finish) and only
12
+ * destroy() when that does not finish inside the cap. A fire-and-forget fetch
13
+ * that rejects inside the window must not surface as a crash on the way out,
14
+ * so a no-op unhandledRejection guard is installed for the window only.
15
+ */
16
+ /** The global symbol Node's bundled undici stores its dispatcher under. */
17
+ export const UNDICI_DISPATCHER = Symbol.for('undici.globalDispatcher.1');
18
+ function capped(p, ms) {
19
+ let timer;
20
+ return Promise.race([
21
+ p.then(() => true, () => true),
22
+ new Promise((r) => { timer = setTimeout(() => r(false), ms); }),
23
+ ]).finally(() => { if (timer)
24
+ clearTimeout(timer); });
25
+ }
26
+ function call(fn, self) {
27
+ try {
28
+ return Promise.resolve(fn?.call(self));
29
+ }
30
+ catch (e) {
31
+ return Promise.reject(e);
32
+ }
33
+ }
34
+ export async function gracefulExit(code, opts = {}) {
35
+ const exit = opts.exit ?? ((c) => process.exit(c));
36
+ const graceMs = opts.graceMs ?? 250;
37
+ process.exitCode = code;
38
+ const guard = () => { };
39
+ process.on('unhandledRejection', guard);
40
+ try {
41
+ const dispatcher = globalThis[UNDICI_DISPATCHER];
42
+ if (dispatcher) {
43
+ let done = false;
44
+ if (typeof dispatcher.close === 'function')
45
+ done = await capped(call(dispatcher.close, dispatcher), graceMs);
46
+ if (!done && typeof dispatcher.destroy === 'function')
47
+ await capped(call(dispatcher.destroy, dispatcher), graceMs);
48
+ }
49
+ // One more loop turn so socket 'close' callbacks run before the teardown.
50
+ await new Promise((r) => setImmediate(r));
51
+ }
52
+ finally {
53
+ process.off('unhandledRejection', guard);
54
+ }
55
+ exit(code);
56
+ }
@@ -0,0 +1,65 @@
1
+ // A command head: "/name" followed by a space or the end of the line —
2
+ // "/cost", "/abap-explain ZFOO". A path ("/sap/bc/adt/…", "/usr/sap/…") has
3
+ // a second "/" in its first token and is prose (final review B M-3).
4
+ const SCRIPT_LINE = /(^|\n)[ \t]*\/[A-Za-z][\w-]*(?=[ \t]|\r?\n|$)/;
5
+ export function readPipedPrompt(stdin, opts = {}) {
6
+ if (stdin.isTTY)
7
+ return Promise.resolve({ kind: 'repl' });
8
+ const firstMs = opts.firstMs ?? 250;
9
+ const endMs = opts.endMs ?? 1500;
10
+ return new Promise((resolve) => {
11
+ const chunks = [];
12
+ let sawData = false;
13
+ let settled = false;
14
+ let t1;
15
+ let t2;
16
+ const text = () => Buffer.concat(chunks).toString('utf8').replace(/^/, '');
17
+ const done = (r) => {
18
+ if (settled)
19
+ return;
20
+ settled = true;
21
+ if (t1)
22
+ clearTimeout(t1);
23
+ if (t2)
24
+ clearTimeout(t2);
25
+ stdin.off('readable', onReadable);
26
+ stdin.off('end', onEnd);
27
+ resolve(r);
28
+ };
29
+ const giveBack = () => {
30
+ if (settled)
31
+ return;
32
+ if (chunks.length > 0)
33
+ stdin.unshift(Buffer.concat(chunks));
34
+ done({ kind: 'repl' });
35
+ };
36
+ const onEnd = () => {
37
+ const t = text().trim();
38
+ done(t ? { kind: 'prompt', text: t } : { kind: 'repl' });
39
+ };
40
+ const onReadable = () => {
41
+ // EOF with nothing read: leave it unread so the REPL sees the end.
42
+ if (!sawData && stdin.readableLength === 0) {
43
+ done({ kind: 'repl' });
44
+ return;
45
+ }
46
+ let c;
47
+ while ((c = stdin.read()) !== null)
48
+ chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
49
+ if (SCRIPT_LINE.test(text())) {
50
+ giveBack();
51
+ return;
52
+ }
53
+ if (!sawData) {
54
+ sawData = true;
55
+ if (t1)
56
+ clearTimeout(t1);
57
+ t2 = setTimeout(giveBack, endMs);
58
+ }
59
+ };
60
+ stdin.on('readable', onReadable);
61
+ stdin.on('end', onEnd);
62
+ t1 = setTimeout(() => { if (!sawData)
63
+ done({ kind: 'repl' }); }, firstMs);
64
+ });
65
+ }
@@ -0,0 +1,13 @@
1
+ // What a saved (spilled) tool output came from, so the tool_output_read row
2
+ // names the object ("ZTTT_GAME") instead of the internal spill file path.
3
+ // In-memory for this process only: after a resume the row reads "saved output".
4
+ const labels = new Map();
5
+ /** File name only, so '/' and '\' spellings of the same path match. */
6
+ const keyOf = (p) => p.split(/[\\/]/).pop() ?? p;
7
+ export function rememberSpillLabel(filePath, label) {
8
+ if (label.trim().length > 0)
9
+ labels.set(keyOf(filePath), label.trim());
10
+ }
11
+ export function spillLabelFor(filePath) {
12
+ return labels.get(keyOf(filePath));
13
+ }
@@ -1,7 +1,7 @@
1
1
  import { select } from '@inquirer/prompts';
2
- import chalk from 'chalk';
2
+ import { formatAttention } from './renderer/notice-shape.js';
3
3
  export async function handleLockConflict(info) {
4
- console.log(chalk.yellow(`\n⚠ ${info.object} is locked by ${info.user} since ${info.lockTime}.`));
4
+ console.log('\n' + formatAttention({ text: `${info.object} is locked`, fact: `by ${info.user} since ${info.lockTime}` }).trimEnd());
5
5
  return (await select({
6
6
  message: 'How do you want to proceed?',
7
7
  choices: [
@@ -8,6 +8,13 @@
8
8
  //
9
9
  // env > local config > server > built-in constant
10
10
  //
11
+ // Task 12 (2026-09-27) adds the CUSTOMER layer (GET /v1/me/model-config, the
12
+ // admin-set per-customer override) for session_default and effort, between
13
+ // local and server: env > local (set) > customer > server > built-in.
14
+ // For managed (enforced) customers this is only a prediction — the proxy
15
+ // enforces and the loop adopts the served model; for BYOK it is a default the
16
+ // developer's local config overrides.
17
+ //
11
18
  // Locked invariant: with NOTHING set (no env, default_model_set false, no server
12
19
  // snapshot) every role resolves to today's constant — byte-identical to before
13
20
  // this feature.
@@ -16,10 +23,11 @@
16
23
  // read is getServerModelConfig(), a synchronous in-memory snapshot the caller
17
24
  // (or the startup fetch) has already populated. `router` is proxy-side only and
18
25
  // is deliberately NOT a CLI role.
26
+ import { DEFAULT_CONFIG } from '../config/loader.js';
19
27
  import { DEFAULT_SESSION_MODEL } from '../config/model-defaults.js';
20
28
  import { PLAN_TIER_SONNET_MODEL } from '../commands/plan-model-tier.js';
21
29
  import { COMPACTION_MODEL } from '../commands/compact.js';
22
- import { getServerModelConfig } from './server-config.js';
30
+ import { getCustomerModelConfig, getServerModelConfig, isEffort } from './server-config.js';
23
31
  /** First non-empty (trimmed) candidate in precedence order, else the built-in. */
24
32
  function firstSet(candidates, builtin) {
25
33
  for (const c of candidates) {
@@ -31,7 +39,8 @@ function firstSet(candidates, builtin) {
31
39
  }
32
40
  /**
33
41
  * Resolve the model for a pipeline role. Precedence: env > local config >
34
- * server > built-in constant.
42
+ * server > built-in constant; `session_default` also reads the customer layer
43
+ * (Task 12) between local and server.
35
44
  *
36
45
  * `session_default` reads the local `default_model` ONLY when `default_model_set`
37
46
  * is true — i.e. the user's config file explicitly carried the key. The value
@@ -46,6 +55,7 @@ export function resolveModelRole(role, cfg, env = process.env) {
46
55
  return firstSet([
47
56
  env.CSPEACH_DEFAULT_MODEL,
48
57
  cfg.default_model_set ? cfg.default_model : undefined,
58
+ getCustomerModelConfig()?.model ?? undefined,
49
59
  server?.roles.session_default,
50
60
  ], DEFAULT_SESSION_MODEL);
51
61
  case 'audit':
@@ -59,3 +69,84 @@ export function resolveModelRole(role, cfg, env = process.env) {
59
69
  }
60
70
  }
61
71
  }
72
+ /**
73
+ * Integration finding N1 (2026-09-28) — the model a call is SENT with. For a
74
+ * managed session-role call of a customer whose model is ENFORCED
75
+ * (/v1/me/model-config `enforced: true`), the proxy serves the customer's
76
+ * model (or, when the row names none, the global session default) whatever
77
+ * the body asks. The CLI sends that model itself, so everything decided from
78
+ * the call's model — deferred vs static tools, max_tokens, block_binding,
79
+ * effort acceptance — is right on call 0, not only after the served id is
80
+ * adopted. Mirrors the proxy's shouldEnforce: session role only (audit,
81
+ * compact and reader are never enforced), managed only (BYOK has no proxy to
82
+ * enforce). Anything else ⇒ `requested` unchanged.
83
+ */
84
+ export function callModelFor(requested, i) {
85
+ if (i.providerMode !== 'managed')
86
+ return requested;
87
+ if ((i.role ?? 'session_default') !== 'session_default')
88
+ return requested;
89
+ const customer = getCustomerModelConfig();
90
+ if (customer?.enforced !== true)
91
+ return requested;
92
+ const enforced = customer.model?.trim() || getServerModelConfig()?.roles.session_default?.trim();
93
+ return enforced || requested;
94
+ }
95
+ // ── Effort (Task 4, 2026-09-27) ─────────────────────────────────────────────
96
+ //
97
+ // Ruling F2: `output_config.effort` is sent only when effort was set by an
98
+ // explicit act. Precedence:
99
+ //
100
+ // env CSPEACH_EFFORT > `config set effort` (effort_set) > served
101
+ // effort.session_default > undefined (⇒ output_config omitted)
102
+ //
103
+ // A bare stored `effort` (the pre-sparse full save wrote effort = "xhigh" into
104
+ // every config.toml) is ignored: loadConfig sets effort_set only when the file
105
+ // also carries the `effort_explicit = true` marker `config set effort` writes.
106
+ // Invalid values at any layer fall through. Task 12 adds the customer layer
107
+ // (/v1/me/model-config) between local and served.
108
+ export function resolveEffort(cfg, env = process.env) {
109
+ const fromEnv = env.CSPEACH_EFFORT?.trim();
110
+ if (isEffort(fromEnv))
111
+ return fromEnv;
112
+ if (cfg.effort_set === true && isEffort(cfg.effort))
113
+ return cfg.effort;
114
+ const customer = getCustomerModelConfig()?.effort;
115
+ if (isEffort(customer))
116
+ return customer;
117
+ const served = getServerModelConfig()?.effort?.session_default;
118
+ if (isEffort(served))
119
+ return served;
120
+ return undefined;
121
+ }
122
+ /**
123
+ * Probe P1 (2026-09-27): claude-haiku-4-5 rejects `output_config.effort` with
124
+ * a 400 ("This model does not support the effort parameter."). Every
125
+ * claude-haiku-4-5* id (incl. dated ids such as claude-haiku-4-5-20251001) is
126
+ * treated as not accepting effort, so the caller drops it — this covers the
127
+ * compact role, which runs on Haiku by default.
128
+ */
129
+ export function modelAcceptsEffort(model) {
130
+ return !model.trim().toLowerCase().startsWith('claude-haiku-4-5');
131
+ }
132
+ /**
133
+ * Task 19 (model control, 2026-09-27, ruling F12) — the effective auto-compact
134
+ * threshold, in context tokens (the last call's input + cache read + cache
135
+ * write):
136
+ *
137
+ * explicit file `[compact] auto_threshold_tokens`
138
+ * > served `auto_compact_tokens` (admin; managed mode)
139
+ * > DEFAULT_CONFIG.compact.auto_threshold_tokens (150 000)
140
+ *
141
+ * "Explicit" is `cfg.compact_threshold_set` (loadConfig): a stored 1 000 000
142
+ * is the old full-save default and counts as unset.
143
+ */
144
+ export function resolveAutoCompactThreshold(cfg) {
145
+ if (cfg.compact_threshold_set === true && Number.isFinite(cfg.compact?.auto_threshold_tokens)) {
146
+ return cfg.compact.auto_threshold_tokens;
147
+ }
148
+ const served = getServerModelConfig()?.auto_compact_tokens;
149
+ if (typeof served === 'number' && Number.isFinite(served))
150
+ return served;
151
+ return DEFAULT_CONFIG.compact.auto_threshold_tokens;
152
+ }