@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
@@ -0,0 +1,263 @@
1
+ import { TOOL_CHANGES_BETA, isToolSearchEntry } from '../tools/tool-loading.js';
2
+ /** Betas the proxy sends on every managed turn (messages.ts `betas: [...]`). */
3
+ export const BASE_BETAS = ['compact-2026-01-12'];
4
+ /** Task 6 — beta that enables `fallbacks: 'default'` (server-side fallback). */
5
+ export const FALLBACK_BETA = 'server-side-fallback-2026-07-01';
6
+ /**
7
+ * Controller ruling (Task 6): an ALLOW-list of the families probe P3 proved
8
+ * accept `fallbacks: 'default'`. Exact match after lowercasing and stripping a
9
+ * trailing -YYYYMMDD — so claude-opus-4-8, a future claude-opus-5-9, or a local
10
+ * alias get no fallbacks and no fallback beta. Mirrors the proxy's
11
+ * models/config.ts modelAcceptsFallbacks.
12
+ */
13
+ export const FALLBACK_MODELS = new Set([
14
+ 'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-4-6', 'claude-haiku-4-5',
15
+ ]);
16
+ export function modelAcceptsFallbacks(model) {
17
+ return FALLBACK_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
18
+ }
19
+ /**
20
+ * Task 7 — beta that enables `thinking.block_binding` (preserved thinking:
21
+ * `prefix_mismatch_behavior: 'drop_block'` drops a thinking block whose prefix
22
+ * changed instead of rejecting the request).
23
+ */
24
+ export const BINDING_BETA = 'thinking-binding-controls-2026-08-01';
25
+ /**
26
+ * Task 7 — ALLOW-list of the families probe P4 proved accept
27
+ * `thinking.block_binding` with BINDING_BETA. Same matching as
28
+ * FALLBACK_MODELS (lowercased, trailing -YYYYMMDD stripped): claude-haiku-4-5*
29
+ * and claude-opus-4-8 were not probed for it, so they get neither the field
30
+ * nor the beta. Mirrors the proxy's models/config.ts modelAcceptsBinding.
31
+ */
32
+ export const BINDING_MODELS = new Set([
33
+ 'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-4-6',
34
+ ]);
35
+ export function modelAcceptsBinding(model) {
36
+ return BINDING_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
37
+ }
38
+ /**
39
+ * Task 8 (ruling F5, spec D9) — ALLOW-list of the families probe P2 proved
40
+ * accept `max_tokens: 65536` (claude-opus-5, claude-opus-5-5). Same matching
41
+ * as FALLBACK_MODELS (lowercased, trailing -YYYYMMDD stripped), so a future
42
+ * claude-opus-5-9 keeps the 32768 default until it is probed.
43
+ */
44
+ export const MAX_TOKENS_65K_MODELS = new Set([
45
+ 'claude-opus-5-5', 'claude-opus-5',
46
+ ]);
47
+ export const SESSION_MAX_TOKENS_DEFAULT = 32768;
48
+ export const SESSION_MAX_TOKENS_OPUS5 = 65536;
49
+ export function sessionMaxTokens(model) {
50
+ return MAX_TOKENS_65K_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''))
51
+ ? SESSION_MAX_TOKENS_OPUS5
52
+ : SESSION_MAX_TOKENS_DEFAULT;
53
+ }
54
+ /**
55
+ * Integration finding I1 (2026-09-28) — the per-model OUTPUT CEILING every
56
+ * request's max_tokens is clamped to, maxTokensOverride included (agent_run
57
+ * children default to 100000). Only the 65k allow-list above is proven;
58
+ * every other model (claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5,
59
+ * unknown ids) is capped at 32768. Dated ids are stripped. Without this an
60
+ * enforced sonnet/haiku child sent 100000 and the API answered 400.
61
+ */
62
+ export function modelOutputCeiling(model) {
63
+ return sessionMaxTokens(model);
64
+ }
65
+ export function clampMaxTokens(model, requested) {
66
+ return Math.min(requested, modelOutputCeiling(model));
67
+ }
68
+ /**
69
+ * Task 16 — the beta for `role: 'system'` `tool_addition` messages (sent
70
+ * whenever a request declares a deferred tool or carries a system message)
71
+ * and the tool search predicate: one definition each, in tools/tool-loading.
72
+ */
73
+ export { TOOL_CHANGES_BETA, isToolSearchEntry };
74
+ function isDeferredEntry(t) {
75
+ return !!t && typeof t === 'object' && t.defer_loading === true;
76
+ }
77
+ /** Task 16 — a tool the request loads in full (neither deferred nor the search tool). */
78
+ export function isLoadedToolEntry(t) {
79
+ return !isDeferredEntry(t) && !isToolSearchEntry(t);
80
+ }
81
+ function hasSystemMessage(messages) {
82
+ return (messages ?? []).some((m) => m?.role === 'system');
83
+ }
84
+ /**
85
+ * Betas matching the fields present in a shaped request. The beta follows the
86
+ * field: the model is gated where the field is set (`fallbacks` in BYOK
87
+ * shaping, `thinking.block_binding` in the loop), not here.
88
+ */
89
+ export function requiredBetas(params) {
90
+ const betas = [...BASE_BETAS];
91
+ if (params.fallbacks)
92
+ betas.push(FALLBACK_BETA);
93
+ if (params.thinking?.block_binding)
94
+ betas.push(BINDING_BETA);
95
+ if ((params.tools ?? []).some(isDeferredEntry) || hasSystemMessage(params.messages))
96
+ betas.push(TOOL_CHANGES_BETA);
97
+ return betas;
98
+ }
99
+ const EPHEMERAL = { type: 'ephemeral' };
100
+ /**
101
+ * Add the system prompt and (optionally) the proxy's two remaining cache
102
+ * breakpoints: the last tool and the last block of the last message. Never
103
+ * mutates the input — the loop's session messages are reused next turn.
104
+ * A caller-supplied `system` is replaced, as the proxy ignores body.system.
105
+ */
106
+ export function withSkillPrompt(params, system, opts) {
107
+ const out = { ...params, system };
108
+ if (!opts.cacheMarkers)
109
+ return out;
110
+ // Task 16 — the marker goes on the last LOADED tool (loaded entries come
111
+ // first, deferred ones and the search tool after); with no deferred entries
112
+ // that is the last tool, as before.
113
+ if (Array.isArray(params.tools) && params.tools.length > 0) {
114
+ const tools = [...params.tools];
115
+ const markIdx = lastIndexWhere(tools, isLoadedToolEntry);
116
+ if (markIdx >= 0) {
117
+ tools[markIdx] = { ...tools[markIdx], cache_control: EPHEMERAL };
118
+ out.tools = tools;
119
+ }
120
+ }
121
+ // Task 16 — the marker goes on the last NON-system message: a trailing
122
+ // `role: 'system'` tool_addition is never marked (probe P7 proved this
123
+ // shape; a marker on a tool_addition block is unproven).
124
+ const lastIdx = lastIndexWhere(params.messages, (m) => m?.role !== 'system');
125
+ if (lastIdx >= 0) {
126
+ const messages = [...params.messages];
127
+ const last = { ...messages[lastIdx] };
128
+ if (typeof last.content === 'string') {
129
+ last.content = [{ type: 'text', text: last.content, cache_control: EPHEMERAL }];
130
+ }
131
+ else if (Array.isArray(last.content) && last.content.length > 0) {
132
+ const blocks = [...last.content];
133
+ blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], cache_control: EPHEMERAL };
134
+ last.content = blocks;
135
+ }
136
+ messages[lastIdx] = last;
137
+ out.messages = messages;
138
+ }
139
+ return out;
140
+ }
141
+ function lastIndexWhere(arr, pred) {
142
+ for (let i = arr.length - 1; i >= 0; i--)
143
+ if (pred(arr[i]))
144
+ return i;
145
+ return -1;
146
+ }
147
+ const LEGACY_TOP_LEVEL = ['model', 'max_tokens', 'messages', 'system', 'tools', 'tool_choice', 'metadata'];
148
+ const LEGACY_THINKING = ['type', 'display'];
149
+ /** Task 16 — server tool blocks (tool search) the legacy wires never saw. */
150
+ const SERVER_TOOL_BLOCKS = new Set(['server_tool_use', 'tool_search_tool_result']);
151
+ /** A shallow copy of an object block without `cache_control`. */
152
+ function withoutCacheControl(b) {
153
+ if (!b || typeof b !== 'object' || !('cache_control' in b))
154
+ return b;
155
+ const { cache_control: _drop, ...rest } = b;
156
+ return rest;
157
+ }
158
+ /** Task 16 — legacy tools: every tool loads (no defer_loading), no search entry, no markers. */
159
+ function legacyTools(tools) {
160
+ return tools
161
+ .filter((t) => !isToolSearchEntry(t))
162
+ .map((t) => {
163
+ const copy = withoutCacheControl(t);
164
+ if (!isDeferredEntry(copy))
165
+ return copy;
166
+ const { defer_loading: _drop, ...rest } = copy;
167
+ return rest;
168
+ });
169
+ }
170
+ /**
171
+ * Task 16 — legacy messages: no `role: 'system'` messages, no server tool
172
+ * blocks (an assistant message left empty by that is dropped), no nested
173
+ * `cache_control` (Task 5 carry).
174
+ */
175
+ function legacyMessages(messages) {
176
+ const out = [];
177
+ for (const m of messages) {
178
+ if (m.role === 'system')
179
+ continue;
180
+ if (!Array.isArray(m.content)) {
181
+ out.push(m);
182
+ continue;
183
+ }
184
+ const content = m.content
185
+ .filter((b) => !SERVER_TOOL_BLOCKS.has(b?.type ?? ''))
186
+ .map(withoutCacheControl);
187
+ if (content.length === 0)
188
+ continue;
189
+ out.push({ ...m, content });
190
+ }
191
+ return out;
192
+ }
193
+ /**
194
+ * Whitelist copy of the fields AI-hub and local accepted before this plan.
195
+ * Every newer field (output_config, fallbacks, betas, thinking.block_binding,
196
+ * …) is dropped, and so are the Task 16 shapes (deferred tools load in full,
197
+ * the search tool, system messages, server tool blocks) and any nested
198
+ * cache_control. Never deletes from the input.
199
+ */
200
+ export function toLegacyShape(params) {
201
+ const src = params;
202
+ const out = {};
203
+ for (const k of LEGACY_TOP_LEVEL) {
204
+ if (src[k] !== undefined)
205
+ out[k] = src[k];
206
+ }
207
+ if (Array.isArray(params.tools))
208
+ out.tools = legacyTools(params.tools);
209
+ if (Array.isArray(params.messages))
210
+ out.messages = legacyMessages(params.messages);
211
+ if (Array.isArray(params.system))
212
+ out.system = params.system.map(withoutCacheControl);
213
+ if (params.thinking !== undefined) {
214
+ const t = params.thinking;
215
+ const thinking = {};
216
+ for (const k of LEGACY_THINKING) {
217
+ if (t[k] !== undefined)
218
+ thinking[k] = t[k];
219
+ }
220
+ out.thinking = thinking;
221
+ }
222
+ return out;
223
+ }
224
+ /**
225
+ * Task 9 (Task 5 carry) — headers only our proxy understands. On a direct
226
+ * path (BYOK talks to the public API) they would leak CSPeach plumbing to a
227
+ * third party, so they are removed before the SDK sees them. Matching is
228
+ * case-insensitive: X-CSForge-* and X-CSPeach-*.
229
+ */
230
+ export function withoutInternalHeaders(headers) {
231
+ const out = {};
232
+ for (const [k, v] of Object.entries(headers ?? {})) {
233
+ if (/^x-cs(forge|peach)-/i.test(k))
234
+ continue;
235
+ out[k] = v;
236
+ }
237
+ return out;
238
+ }
239
+ /**
240
+ * Task 9 — the role header is new proxy plumbing; AI-hub keeps today's header
241
+ * set, so only X-CSPeach-Model-Role is removed there.
242
+ */
243
+ export function withoutRoleHeader(headers) {
244
+ const out = {};
245
+ for (const [k, v] of Object.entries(headers ?? {})) {
246
+ if (k.toLowerCase() === 'x-cspeach-model-role')
247
+ continue;
248
+ out[k] = v;
249
+ }
250
+ return out;
251
+ }
252
+ /**
253
+ * The active skill travels as the X-CSForge-Skill header (coordination §1).
254
+ * Direct providers need it to fetch the skill body; without it the model
255
+ * would run with no skill prompt, so fail loudly instead.
256
+ */
257
+ export function requireSkillName(options, mode) {
258
+ const name = options.headers?.['X-CSForge-Skill'];
259
+ if (!name) {
260
+ throw new Error(`${mode} request is missing the X-CSForge-Skill header — cannot load the skill prompt for this turn.`);
261
+ }
262
+ return name;
263
+ }
@@ -2,6 +2,8 @@ import Anthropic from '@anthropic-ai/sdk';
2
2
  import keychain from '../../auth/keychain.js';
3
3
  import { createManifestSkillSource } from '../../skills/source-manifest.js';
4
4
  import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
5
+ import { requireSkillName, toLegacyShape, withoutRoleHeader } from '../provider-shape.js';
6
+ import { systemPromptForSkill } from '../system-prompt.js';
5
7
  const KEYCHAIN_SERVICE = 'cspeach.ai-hub';
6
8
  const KEYCHAIN_ACCOUNT = 'token';
7
9
  export function createAiHubProvider(cfg) {
@@ -16,14 +18,27 @@ export function createAiHubProvider(cfg) {
16
18
  mode: 'ai-hub',
17
19
  skillSource,
18
20
  async createStream(params, options = {}) {
21
+ // Task 5 (ruling F3) — AI-hub keeps today's field set only (newer
22
+ // fields such as output_config are stripped) and gains the preamble +
23
+ // skill body as `system`. No cache markers: the hub has no flag saying
24
+ // it accepts cache_control, so none is sent.
25
+ const skillName = requireSkillName(options, 'AI Hub');
19
26
  const token = await getToken();
27
+ // Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
28
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false });
29
+ const request = {
30
+ ...toLegacyShape(params),
31
+ system,
32
+ model: modelAlias,
33
+ };
20
34
  const sdk = new Anthropic({
21
35
  apiKey: token,
22
36
  baseURL,
23
- defaultHeaders: { ...options.headers },
37
+ // Task 9 — the proxy-only role header is not forwarded to the hub.
38
+ defaultHeaders: withoutRoleHeader(options.headers),
24
39
  });
25
40
  // v0.6 — pass AbortSignal so hard-interrupt tears down the stream.
26
- return sdk.messages.stream({ ...params, model: modelAlias }, options.signal ? { signal: options.signal } : undefined);
41
+ return sdk.messages.stream(request, options.signal ? { signal: options.signal } : undefined);
27
42
  },
28
43
  async healthCheck() {
29
44
  const token = await keychain.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
@@ -2,23 +2,53 @@ import Anthropic from '@anthropic-ai/sdk';
2
2
  import keychain from '../../auth/keychain.js';
3
3
  import { createManifestSkillSource } from '../../skills/source-manifest.js';
4
4
  import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
5
+ import { modelAcceptsFallbacks, requiredBetas, requireSkillName, withoutInternalHeaders, withSkillPrompt } from '../provider-shape.js';
6
+ import { systemPromptForSkill } from '../system-prompt.js';
5
7
  const KEYCHAIN_SERVICE = 'cspeach.anthropic';
6
8
  const KEYCHAIN_ACCOUNT = 'api-key';
7
9
  // Cheapest Anthropic model available at doctor-probe time.
8
10
  const HEALTH_PROBE_MODEL = 'claude-haiku-4-6-latest';
9
11
  export function createByokProvider(_cfg) {
10
12
  const skillSource = createManifestSkillSource(DEFAULT_MANIFEST_URL);
13
+ // Task 5 (ruling F3) — the loop's request is provider-neutral; BYOK talks to
14
+ // the model directly, so it adds what the proxy adds for managed: preamble +
15
+ // skill body as `system`, cache markers, and the betas matching the fields
16
+ // present. Shared by createStream and keepalive (Task 15) so a ping warms
17
+ // exactly the prefix the real call wrote.
18
+ async function shapeRequest(params, options) {
19
+ const skillName = requireSkillName(options, 'BYOK');
20
+ const key = await getKey();
21
+ // Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
22
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: true });
23
+ // Task 6 — opt into server-side fallback, only for the families probe
24
+ // P3 proved (allow-list); requiredBetas adds its beta.
25
+ const prompted = withSkillPrompt(params, system, { cacheMarkers: true });
26
+ const shaped = modelAcceptsFallbacks(params.model)
27
+ ? { ...prompted, fallbacks: 'default' }
28
+ : prompted;
29
+ const betas = requiredBetas(shaped);
30
+ // Task 9 — CSPeach-internal headers never reach the public API.
31
+ const sdk = new Anthropic({ apiKey: key, defaultHeaders: withoutInternalHeaders(options.headers) });
32
+ return { sdk, body: { ...shaped, betas } };
33
+ }
11
34
  return {
12
35
  name: 'anthropic-byok',
13
36
  mode: 'byok',
14
37
  skillSource,
15
38
  async createStream(params, options = {}) {
16
- const key = await getKey();
17
- const sdk = new Anthropic({ apiKey: key, defaultHeaders: { ...options.headers } });
39
+ const { sdk, body } = await shapeRequest(params, options);
18
40
  // v0.6 — pass AbortSignal so the agent loop's per-turn AbortController
19
41
  // can tear down the stream on Esc / SIGINT-during-turn. The Anthropic
20
42
  // SDK's `signal` option propagates to the underlying fetch.
21
- return sdk.messages.stream(params, options.signal ? { signal: options.signal } : undefined);
43
+ return sdk.beta.messages.stream(body, options.signal ? { signal: options.signal } : undefined);
44
+ },
45
+ // Task 15 — keepalive ping, direct to the API: the SAME shaped body as
46
+ // createStream (system, cache markers, betas, fallbacks) with
47
+ // max_tokens 0 and no stream (probe P5: accepted, reads the cache). No
48
+ // SDK retries — a failed ping stops that wait, it must never storm.
49
+ async keepalive(params, options = {}) {
50
+ const { sdk, body } = await shapeRequest(params, options);
51
+ await sdk.beta.messages.create({ ...body, max_tokens: 0 }, { maxRetries: 0, timeout: 20_000 });
22
52
  },
23
53
  async healthCheck() {
24
54
  const key = await keychain.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
@@ -1,9 +1,12 @@
1
- import OpenAI from 'openai';
1
+ // Loaded on use (fix round 3, item 11e): only local mode needs the OpenAI SDK.
2
+ const loadOpenAI = async () => (await import('openai')).default;
2
3
  import crypto from 'node:crypto';
3
4
  import { sha256 } from '@noble/hashes/sha256';
4
5
  import { bytesToHex } from '@noble/hashes/utils';
5
6
  import { createBundledSkillSource } from '../../skills/source-bundled.js';
6
7
  import { BUNDLED_SKILLS } from '../../skills/bundled-skills.js';
8
+ import { requireSkillName } from '../provider-shape.js';
9
+ import { buildSystemPrompt } from '../system-prompt.js';
7
10
  export function createLocalProvider(cfg) {
8
11
  if (!cfg.llm.local_base_url) {
9
12
  throw new Error('local mode requires llm.local_base_url — run: cspeach config set llm.local_base_url http://localhost:11434/v1');
@@ -19,8 +22,14 @@ export function createLocalProvider(cfg) {
19
22
  if (params.tools && params.tools.length > 0) {
20
23
  throw new Error('Tool calling not supported in local mode (Plan A). Switch to managed/byok/ai-hub, or wait for the local-mode tool-calling follow-up PR.');
21
24
  }
25
+ // Task 5 (ruling F3) — preamble + bundled skill body as the system
26
+ // message (no cache markers). anthropicParamsToOpenAI copies only
27
+ // max_tokens / messages / system, so newer fields never reach the endpoint.
28
+ const skillName = requireSkillName(options, 'Local');
29
+ const skill = await skillSource.getSkillBody(skillName);
30
+ const OpenAI = await loadOpenAI();
22
31
  const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
23
- const openAiParams = anthropicParamsToOpenAI(params, model);
32
+ const openAiParams = anthropicParamsToOpenAI({ ...params, system: buildSystemPrompt(skill.body, { cacheMarkers: false }) }, model);
24
33
  // stream: true widens via object spread; the literal-true overload cannot be
25
34
  // selected by structural inference, so cast to the streaming return shape.
26
35
  // v0.6 — pass AbortSignal through OpenAI SDK request options.
@@ -29,6 +38,7 @@ export function createLocalProvider(cfg) {
29
38
  },
30
39
  async healthCheck() {
31
40
  try {
41
+ const OpenAI = await loadOpenAI();
32
42
  const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
33
43
  await sdk.chat.completions.create({
34
44
  model,
@@ -13,15 +13,38 @@
13
13
  *
14
14
  * Drop incomplete tool_use blocks. The model will re-emit them on the next
15
15
  * round (the prior tool round's results are already on session.messages,
16
- * giving the model the same context). Text and thinking blocks are kept
17
- * even if truncated — partial prose is still useful for the user to read,
18
- * and a short paragraph fragment is a valid assistant message.
16
+ * giving the model the same context). Text blocks are kept even if
17
+ * truncated — partial prose is still useful for the user to read, and a
18
+ * short paragraph fragment is a valid assistant message. Thinking blocks are
19
+ * kept only when complete and signed (see isUnsignedThinking).
19
20
  */
20
- export function repairPartialBlocks(blocks) {
21
+ /**
22
+ * Block types whose input streams as `input_json_delta` (and so may carry a
23
+ * residual `partial_json`). Task 16 (F6): the tool search tool's
24
+ * `server_tool_use` streams its input the same way as a client `tool_use`
25
+ * (probe P6: 5 and 8 input_json_delta events).
26
+ */
27
+ export const STREAMED_INPUT_BLOCKS = new Set(['tool_use', 'server_tool_use']);
28
+ /**
29
+ * 2026-09-27 — a thinking block is only valid to send back to the API with its
30
+ * complete signature. The signature arrives last (`signature_delta`, right
31
+ * before `content_block_stop`), so a turn cancelled mid-thinking leaves a block
32
+ * with `signature: ''` — and every later request 400s with
33
+ * messages.N.content.M: Invalid `signature` in `thinking` block
34
+ * A redacted_thinking block is likewise only valid with its `data`.
35
+ */
36
+ export function isUnsignedThinking(b) {
37
+ if (b?.type === 'thinking')
38
+ return typeof b.signature !== 'string' || b.signature.length === 0;
39
+ if (b?.type === 'redacted_thinking')
40
+ return typeof b.data !== 'string' || b.data.length === 0;
41
+ return false;
42
+ }
43
+ export function repairPartialBlocks(blocks, opts = {}) {
21
44
  return blocks.filter((b) => {
22
45
  if (b == null || typeof b !== 'object')
23
46
  return false;
24
- if (b.type === 'tool_use') {
47
+ if (STREAMED_INPUT_BLOCKS.has(b.type)) {
25
48
  // Complete only when input was successfully parsed (object, not undefined).
26
49
  if (b.input === undefined)
27
50
  return false;
@@ -35,10 +58,72 @@ export function repairPartialBlocks(blocks) {
35
58
  delete b.partial_json;
36
59
  return true;
37
60
  }
38
- // text, thinking, redacted_thinking, etc. — keep as-is.
61
+ if (b.type === 'thinking' || b.type === 'redacted_thinking') {
62
+ // Never persist a partial thinking block: no signature, or cut before
63
+ // its content_block_stop. The model re-thinks on the next round.
64
+ if (isUnsignedThinking(b))
65
+ return false;
66
+ if (opts.completed && !opts.completed.has(b))
67
+ return false;
68
+ return true;
69
+ }
70
+ // text etc. — keep as-is (partial prose is still useful and valid).
39
71
  return true;
40
72
  });
41
73
  }
74
+ /**
75
+ * Task 6 (2026-09-27) — server-side fallback. When the first model hands off
76
+ * to a fallback model mid-output, the message carries a `fallback` block. The
77
+ * API rule for echoing it back: omit the `thinking`, `redacted_thinking` and
78
+ * `tool_use` blocks that precede the LAST `fallback` block (they belong to
79
+ * the model that did not finish). Everything else is kept in order.
80
+ *
81
+ * No `fallback` block ⇒ the same array reference is returned (no copy).
82
+ */
83
+ // Task 16 review — also the tool search tool's server blocks (conservative,
84
+ // same rule as tool_use: a server tool call of the model that did not finish).
85
+ const PRE_FALLBACK_DROP = new Set([
86
+ 'thinking', 'redacted_thinking', 'tool_use', 'server_tool_use', 'tool_search_tool_result',
87
+ ]);
88
+ export function stripPreFallbackBlocks(blocks) {
89
+ let last = -1;
90
+ for (let i = blocks.length - 1; i >= 0; i--) {
91
+ if (blocks[i]?.type === 'fallback') {
92
+ last = i;
93
+ break;
94
+ }
95
+ }
96
+ if (last < 0)
97
+ return blocks;
98
+ return blocks.filter((b, i) => i >= last || !PRE_FALLBACK_DROP.has(b?.type));
99
+ }
100
+ /**
101
+ * Heal a session already poisoned by an unsigned thinking block (saved by a
102
+ * CLI before 2026-09-27): drop every thinking / redacted_thinking block that
103
+ * lacks its signature, and drop a message left with no content (the API
104
+ * rejects empty content; consecutive user turns are merged by the API).
105
+ * Mutates `messages`; returns the number of blocks removed. Idempotent.
106
+ */
107
+ export function stripUnsignedThinking(messages) {
108
+ if (!messages)
109
+ return 0;
110
+ let removed = 0;
111
+ for (let i = messages.length - 1; i >= 0; i--) {
112
+ const msg = messages[i];
113
+ if (!Array.isArray(msg?.content))
114
+ continue;
115
+ const kept = msg.content.filter((b) => !isUnsignedThinking(b));
116
+ const dropped = msg.content.length - kept.length;
117
+ if (dropped === 0)
118
+ continue;
119
+ removed += dropped;
120
+ if (kept.length === 0)
121
+ messages.splice(i, 1);
122
+ else
123
+ msg.content = kept;
124
+ }
125
+ return removed;
126
+ }
42
127
  /**
43
128
  * In-memory heal for a poisoned session.messages array.
44
129
  *
@@ -64,7 +149,7 @@ export function healSessionMessagesInPlace(messages) {
64
149
  if (!Array.isArray(msg.content))
65
150
  continue;
66
151
  for (const block of msg.content) {
67
- if (block?.type === 'tool_use' && typeof block.partial_json === 'string') {
152
+ if (STREAMED_INPUT_BLOCKS.has(block?.type) && typeof block.partial_json === 'string') {
68
153
  if (block.input === undefined) {
69
154
  try {
70
155
  block.input = JSON.parse(block.partial_json);
@@ -167,3 +252,36 @@ export function healOrphanToolUses(messages) {
167
252
  }
168
253
  return added;
169
254
  }
255
+ /**
256
+ * Review M2 — true when the message holds at least one thinking /
257
+ * redacted_thinking block and nothing else readable. Integration M1 — a
258
+ * `fallback` block (Task 6) is neutral: [fallback, thinking] is still
259
+ * thinking-only; [fallback] alone is not.
260
+ */
261
+ export function isThinkingOnly(blocks) {
262
+ if (!Array.isArray(blocks))
263
+ return false;
264
+ const rest = blocks.filter((b) => b?.type !== 'fallback');
265
+ return rest.length > 0
266
+ && rest.every((b) => b?.type === 'thinking' || b?.type === 'redacted_thinking');
267
+ }
268
+ /**
269
+ * Review M2 — a turn cut right after a signed thinking block (before any
270
+ * text or tool call) left an assistant message holding only thinking. Drop
271
+ * it when it is the LAST assistant message; earlier turns are never touched
272
+ * (removing a block mid-transcript would invalidate later signed blocks).
273
+ * Mutates `messages`; returns the number of messages removed. Idempotent.
274
+ */
275
+ export function dropTrailingThinkingOnly(messages) {
276
+ if (!messages)
277
+ return 0;
278
+ for (let i = messages.length - 1; i >= 0; i--) {
279
+ if (messages[i]?.role !== 'assistant')
280
+ continue;
281
+ if (!isThinkingOnly(messages[i].content))
282
+ return 0;
283
+ messages.splice(i, 1);
284
+ return 1;
285
+ }
286
+ return 0;
287
+ }
@@ -29,7 +29,12 @@ export async function summariseViaProvider(provider, system, user, opts) {
29
29
  system,
30
30
  messages: [{ role: 'user', content: user }],
31
31
  }, {
32
- headers: opts.headers,
32
+ // Task 9 — every caller is /compact, so the role defaults to 'compact'
33
+ // (the proxy never enforces the session model on it). A caller-set
34
+ // X-CSPeach-Model-Role wins. No headers ⇒ unchanged (undefined).
35
+ headers: opts.headers
36
+ ? { 'X-CSPeach-Model-Role': 'compact', ...opts.headers }
37
+ : opts.headers,
33
38
  signal: opts.signal,
34
39
  });
35
40
  let accumulated = '';
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Client-side system prompt for providers that talk to a model directly
3
+ * (BYOK, AI-hub, local). Task 5 (2026-09-27, ruling F3).
4
+ *
5
+ * Mirrors the proxy's managed system prompt (cspeach-proxy/src/routes/
6
+ * messages.ts): two text blocks — the CSPeach principles preamble, then the
7
+ * active skill's SKILL.md body — each carrying an ephemeral cache marker when
8
+ * the provider supports them.
9
+ */
10
+ import { FORGE_PRINCIPLES_PREAMBLE } from '../skills/preamble.js';
11
+ import { READER_SKILL, READER_SYSTEM_PROMPT } from '../tools/subagent/reader-prompt.js';
12
+ export function buildSystemPrompt(skillBody, opts) {
13
+ const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
14
+ return [
15
+ { type: 'text', text: FORGE_PRINCIPLES_PREAMBLE, ...marker },
16
+ { type: 'text', text: skillBody, ...marker },
17
+ ];
18
+ }
19
+ /**
20
+ * Task 18 (ruling F8) — a reader subagent's system prompt: the pinned
21
+ * READER_SYSTEM_PROMPT alone (no preamble, no skill body), as the proxy sends
22
+ * it for the `_reader` skill header.
23
+ */
24
+ export function buildReaderSystemPrompt(opts) {
25
+ const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
26
+ return [{ type: 'text', text: READER_SYSTEM_PROMPT, ...marker }];
27
+ }
28
+ /**
29
+ * The system prompt for the skill named in the X-CSForge-Skill header. The
30
+ * internal `_reader` skill has no SKILL.md, so it never reaches the skill
31
+ * source (a lookup would fail).
32
+ */
33
+ export async function systemPromptForSkill(skillName, skillSource, opts) {
34
+ if (skillName === READER_SKILL)
35
+ return buildReaderSystemPrompt(opts);
36
+ const skill = await skillSource.getSkillBody(skillName);
37
+ return buildSystemPrompt(skill.body, opts);
38
+ }