@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
@@ -3,11 +3,20 @@ import { getTool, isStandalone } from '../tools/index.js';
3
3
  import { NOT_CONNECTED_MESSAGE } from '../sap/offline-adt-client.js';
4
4
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
5
5
  import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
6
+ import { isCredentialHandoverApproved, markCredentialHandoverApproved, } from '../repl/credential-handover-gate.js';
7
+ import { READER_TOOLS } from '../tools/subagent/reader-prompt.js';
8
+ const READER_TOOL_SET = new Set(READER_TOOLS);
6
9
  export async function dispatchTool(name, args, ctx) {
7
10
  const tool = getTool(name);
8
11
  if (!tool) {
9
12
  return { content: JSON.stringify({ error: `unknown_tool: ${name}` }), is_error: true };
10
13
  }
14
+ // Task 18 (ruling F22) — a reader subagent is read-only and depth 1: only
15
+ // its fixed tool list runs, whatever the model calls (no writes, no
16
+ // questions or approvals, no nested read_agent / agent_run, no shell).
17
+ if (ctx.readerMode && !READER_TOOL_SET.has(name)) {
18
+ return { content: `error: read-only reader — ${name} is not allowed here`, is_error: true };
19
+ }
11
20
  // Standalone safety net (2026-09-16). SAP-dependent tools are already
12
21
  // filtered out of the list the model sees (agent/loop.ts), but a model can
13
22
  // still call a tool it remembers from an earlier turn or from training. Fail
@@ -23,6 +32,37 @@ export async function dispatchTool(name, args, ctx) {
23
32
  is_error: true,
24
33
  };
25
34
  }
35
+ // L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
36
+ // login to a local child process ALWAYS asks a human, once per session per
37
+ // alias, even as the first mutating call of a turn (where Rule 8 below stays
38
+ // silent). Headless runs are declined, never auto-approved.
39
+ // The hook may be async (it reads the app's files). A hook that throws is
40
+ // doubt — never let the call through unprompted: fail the call instead.
41
+ let handover = null;
42
+ try {
43
+ handover = (await tool.credentialHandover?.(args, ctx)) ?? null;
44
+ }
45
+ catch (err) {
46
+ return {
47
+ content: JSON.stringify({ error: 'credential_handover_check_failed', reason: String(err) }),
48
+ is_error: true,
49
+ };
50
+ }
51
+ const sessionId = ctx.session?.id;
52
+ if (handover && !isCredentialHandoverApproved(sessionId, handover.alias)) {
53
+ const r = await presentSafetyConfirmation({
54
+ rule: 'CREDENTIAL_HANDOVER',
55
+ op: tool.name,
56
+ object: { type: 'SAP login', name: handover.alias },
57
+ what_will_happen: handover.summary,
58
+ rollback_available: false, // a handed-over secret cannot be taken back
59
+ transport: '',
60
+ args,
61
+ });
62
+ if (!r.confirmed)
63
+ return declined(r);
64
+ markCredentialHandoverApproved(sessionId, handover.alias);
65
+ }
26
66
  // Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
27
67
  // explicit confirmation. Fires regardless of /safety-mode toggle (batch
28
68
  // detection is unconditional per Plan 3 §6.6).
@@ -49,28 +89,10 @@ export async function dispatchTool(name, args, ctx) {
49
89
  rollback_available: true, // snapshots taken per-write
50
90
  transport: typeof args?.transport === 'string' ? args.transport : '',
51
91
  batch_count: getWriteOpsThisTurn().length + 1,
92
+ args,
52
93
  });
53
- if (!r.confirmed) {
54
- // B5 — discriminate WHO declined. A headless fail-fast (nobody ever
55
- // saw the card) must not masquerade as a user decision: the model
56
- // (and any transcript reader) reacts differently to "the user said
57
- // no" vs "no user was available to say yes". Uniform `headless: true`
58
- // marker matches the other headless result payloads.
59
- if (r.headless) {
60
- return {
61
- content: JSON.stringify({
62
- error: 'headless_safety_decline',
63
- headless: true,
64
- reason: r.reason,
65
- }),
66
- is_error: true,
67
- };
68
- }
69
- return {
70
- content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
71
- is_error: true,
72
- };
73
- }
94
+ if (!r.confirmed)
95
+ return declined(r);
74
96
  }
75
97
  try {
76
98
  const result = await tool.handler(args, ctx);
@@ -88,6 +110,29 @@ export async function dispatchTool(name, args, ctx) {
88
110
  return { content: JSON.stringify({ error: String(err) }), is_error: true };
89
111
  }
90
112
  }
113
+ /**
114
+ * B5 — discriminate WHO declined a safety card. A headless fail-fast (nobody
115
+ * ever saw the card) must not masquerade as a user decision: the model (and
116
+ * any transcript reader) reacts differently to "the user said no" vs "no user
117
+ * was available to say yes". Uniform `headless: true` marker matches the
118
+ * other headless result payloads.
119
+ */
120
+ function declined(r) {
121
+ if (r.headless) {
122
+ return {
123
+ content: JSON.stringify({
124
+ error: 'headless_safety_decline',
125
+ headless: true,
126
+ reason: r.reason,
127
+ }),
128
+ is_error: true,
129
+ };
130
+ }
131
+ return {
132
+ content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
133
+ is_error: true,
134
+ };
135
+ }
91
136
  /**
92
137
  * True for local project work: the filesystem tools (confined to the project
93
138
  * folder by resolveSafePath) and shell_exec (a hardcoded safelist of local build
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Final review I2 (controller ruling, 2026-09-28) — the tool-loading mode is
3
+ * pinned on the SESSION, not resolved per process.
4
+ *
5
+ * The first request pins `session.tool_loading` to the mode that request
6
+ * uses (the process-resolved mode, gated by the model allow-list). A resumed
7
+ * session keeps its pinned mode: a 'static' session never turns deferred
8
+ * (its `tools[]` stays byte-stable), and a 'deferred' session stays deferred
9
+ * while the call allows it.
10
+ *
11
+ * A 'deferred' session must become 'static' when a call cannot take the
12
+ * deferred shapes: the admin kill switch, a failed startup config fetch
13
+ * (static until restart), `CSPEACH_TOOL_LOADING=static`, or a call on a model
14
+ * outside the allow-list (a plan-tier sonnet turn, an adopted opus-4-8). Its
15
+ * history then still holds deferred artefacts — `server_tool_use` /
16
+ * `tool_search_tool_result` blocks and `role: 'system'` tool_addition
17
+ * messages — which a request with no search tool and no deferred entries
18
+ * cannot carry. They are stripped ONCE, before that request, and the session
19
+ * is re-pinned 'static' so the strip never repeats. This is a deliberate
20
+ * one-time prefix rewrite, like /compact: every assistant message from the
21
+ * first edit on loses its thinking blocks (the API binds thinking to the exact
22
+ * prefix that produced it), the same stripThinking /compact and the cold
23
+ * prune apply.
24
+ */
25
+ import { stripThinking } from '../commands/compact.js';
26
+ import { modelAcceptsDeferredTools } from '../tools/tool-loading.js';
27
+ const SERVER_TOOL_BLOCK_TYPES = new Set(['server_tool_use', 'tool_search_tool_result']);
28
+ /** True when the history holds a shape only a deferred-mode request may carry. */
29
+ export function hasDeferredArtefacts(messages) {
30
+ for (const m of messages ?? []) {
31
+ if (m?.role === 'system')
32
+ return true;
33
+ if (Array.isArray(m?.content) && m.content.some((b) => SERVER_TOOL_BLOCK_TYPES.has(b?.type)))
34
+ return true;
35
+ }
36
+ return false;
37
+ }
38
+ /**
39
+ * Remove every deferred artefact from `messages` in place: `role: 'system'`
40
+ * messages, and `server_tool_use` / `tool_search_tool_result` blocks (a
41
+ * message left with no content is dropped). Every assistant message from the
42
+ * first edit on loses its thinking blocks (a thinking-only message keeps a
43
+ * placeholder text). Returns the number of messages and blocks removed; 0 ⇒
44
+ * untouched. Idempotent.
45
+ */
46
+ export function stripDeferredArtefacts(messages) {
47
+ let removed = 0;
48
+ let firstEdit = -1;
49
+ const out = [];
50
+ for (const m of messages) {
51
+ if (m?.role === 'system') {
52
+ removed++;
53
+ if (firstEdit < 0)
54
+ firstEdit = out.length;
55
+ continue;
56
+ }
57
+ if (Array.isArray(m?.content)) {
58
+ const kept = m.content.filter((b) => !SERVER_TOOL_BLOCK_TYPES.has(b?.type));
59
+ if (kept.length !== m.content.length) {
60
+ removed += m.content.length - kept.length;
61
+ if (firstEdit < 0)
62
+ firstEdit = out.length;
63
+ if (kept.length > 0)
64
+ out.push({ ...m, content: kept });
65
+ continue;
66
+ }
67
+ }
68
+ out.push(m);
69
+ }
70
+ if (removed === 0)
71
+ return 0;
72
+ for (let i = firstEdit; i < out.length; i++)
73
+ out[i] = stripThinking(out[i]);
74
+ messages.splice(0, messages.length, ...out);
75
+ return removed;
76
+ }
77
+ /**
78
+ * Decide the tool-loading mode for one call and keep the session consistent
79
+ * with it. `resolved` is the process-resolved mode (resolveToolLoading);
80
+ * `model` is the model this call sends. Mutates `session.tool_loading` and,
81
+ * on a deferred → static change, `session.messages` (see the file comment).
82
+ */
83
+ export function settleSessionToolLoading(session, i) {
84
+ const allowed = i.resolved === 'deferred' && modelAcceptsDeferredTools(i.model) ? 'deferred' : 'static';
85
+ // A session saved before the pin existed: its history tells how it ran.
86
+ const pinned = session.tool_loading
87
+ ?? (hasDeferredArtefacts(session.messages) ? 'deferred' : undefined);
88
+ if (pinned === undefined) {
89
+ session.tool_loading = allowed;
90
+ return { mode: allowed, stripped: 0 };
91
+ }
92
+ if (pinned === 'deferred' && allowed === 'deferred') {
93
+ session.tool_loading = 'deferred';
94
+ return { mode: 'deferred', stripped: 0 };
95
+ }
96
+ // Static for this call (and from now on): strip once, pin 'static'.
97
+ session.tool_loading = 'static';
98
+ const stripped = pinned === 'deferred' ? stripDeferredArtefacts(session.messages) : 0;
99
+ return { mode: 'static', stripped };
100
+ }
@@ -5,8 +5,8 @@
5
5
  * everything. The new behaviour:
6
6
  *
7
7
  * - Writes the full error (message + stack) to ~/.cspeach/logs/<sessionId>-<ts>.log
8
- * - Prints a clean 4-6 line block to stderr with: cause, session id,
9
- * stream path, log path, resume hint.
8
+ * - Prints the piece 2 failure shape to stderr: cause, session id,
9
+ * stream path, log path and resume facts as dim detail, then one Next: line.
10
10
  * - Never throws — best-effort. The CLI returns to the prompt cleanly so
11
11
  * the user can either resume or type a new prompt.
12
12
  *
@@ -14,7 +14,9 @@
14
14
  * pushed any partial assistant content onto session.messages and saved
15
15
  * the session before the throw bubbled here. Resume picks up that state.
16
16
  */
17
- import chalk from 'chalk';
17
+ import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
18
+ import { paint } from '../renderer/theme.js';
19
+ import { getLook } from '../renderer/look.js';
18
20
  import { promises as fs } from 'node:fs';
19
21
  import * as os from 'node:os';
20
22
  import * as path from 'node:path';
@@ -48,7 +50,37 @@ export function describeError(err) {
48
50
  return 'network timeout';
49
51
  if (msg.toLowerCase().includes('aborterror'))
50
52
  return 'request aborted';
51
- return msg.slice(0, 200);
53
+ const api = describeApiError(err, msg);
54
+ if (api)
55
+ return api;
56
+ return msg.replace(/\s+/g, ' ').trim().slice(0, 200);
57
+ }
58
+ /**
59
+ * 2026-09-27 — an upstream API error arrives as `Error: 400 {"type":"error",
60
+ * "error":{…,"message":"…"},"request_id":"…"}`. Show ONE readable line
61
+ * (`service error 400: <message>`), never the raw JSON body.
62
+ */
63
+ function describeApiError(err, msg) {
64
+ const brace = msg.indexOf('{');
65
+ if (brace < 0)
66
+ return null;
67
+ const end = msg.lastIndexOf('}');
68
+ let body;
69
+ try {
70
+ body = JSON.parse(msg.slice(brace, end + 1));
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ if (!body || typeof body !== 'object')
76
+ return null;
77
+ const head = msg.slice(0, brace).match(/(\d{3})\s*$/);
78
+ const statusRaw = head?.[1] ?? err?.status;
79
+ const status = statusRaw != null && /^\d{3}$/.test(String(statusRaw)) ? String(statusRaw) : null;
80
+ const inner = body?.error?.message ?? body?.message;
81
+ const text = typeof inner === 'string' ? inner.replace(/\s+/g, ' ').trim() : '';
82
+ const label = status ? `service error ${status}` : 'service error';
83
+ return (text ? `${label}: ${text}` : label).slice(0, 200);
52
84
  }
53
85
  /**
54
86
  * Write a full diagnostic log to ~/.cspeach/logs/<sessionId>-<ts>.log.
@@ -96,38 +128,57 @@ async function writeErrorLog(sessionId, err) {
96
128
  return null;
97
129
  }
98
130
  }
131
+ /** Print a multi-line block through the line-based `print` sink. */
132
+ function printBlock(print, block) {
133
+ for (const l of block.replace(/\n$/, '').split('\n'))
134
+ print(l);
135
+ }
99
136
  /**
100
- * Render the user-facing turn-error block. Writes a log file (best-effort)
101
- * and prints a 4-6 line diagnostic that names the cause + the recovery path.
137
+ * Render the user-facing turn-error block (piece 2 §9 failure shape). Writes a
138
+ * log file (best-effort) and prints the cause plus the Session, Stream, Log and
139
+ * Resume facts as dim detail, ending with ONE Next: line — "type continue"
140
+ * inside a live session, `cspeach --resume <id>` otherwise.
102
141
  */
103
142
  export async function renderTurnError(input) {
104
143
  const print = input.print ?? ((line) => process.stderr.write(line + '\n'));
144
+ const id = input.session.id;
145
+ const resumeRows = [
146
+ `Resume: cspeach --resume ${id}`,
147
+ ` cspeach --resume (no arg → pick from a list)`,
148
+ ];
105
149
  print('');
106
150
  if (input.userInterrupted) {
107
- // User pressed Ctrl+C / Esc — frame as a deliberate cancellation,
108
- // not a crash. Skip the log file (no diagnostic value).
109
- print(chalk.yellow(`✋ Turn cancelled.`));
110
- print(chalk.gray(` Session: ${input.session.id}`));
111
- print(chalk.gray(` Resume: cspeach --resume ${input.session.id}`));
112
- print(chalk.gray(` cspeach --resume (no arg → pick from a list)`));
151
+ // User pressed Ctrl+C / Esc — a deliberate cancellation, not a crash.
152
+ // Skip the log file (no diagnostic value). Round 6 item 4d — the panels
153
+ // shape: ONE notice line. Review M4 — the legacy look keeps its rows.
154
+ if (getLook() === 'panels') {
155
+ printBlock(print, formatInfo(input.inSession
156
+ ? 'Turn cancelled · your work so far is kept · type continue to pick up'
157
+ : `Turn cancelled · resume: cspeach --resume ${id}`));
158
+ }
159
+ else {
160
+ printBlock(print, formatInfo('Turn cancelled'));
161
+ for (const r of [`Session: ${id}`, ...resumeRows])
162
+ print(` ${paint('dim', r)}`);
163
+ }
113
164
  print('');
114
165
  return;
115
166
  }
116
167
  const reason = describeError(input.err);
117
- const logPath = await writeErrorLog(input.session.id, input.err);
118
- print(chalk.red(`⚠ Turn interrupted: ${reason}`));
119
- if (input.inSession) {
120
- print(chalk.yellow(` Your work so far is saved. Type continue to pick up where it stopped.`));
121
- }
122
- print(chalk.gray(` Session: ${input.session.id}`));
123
- if (input.streamFilePath) {
124
- print(chalk.gray(` Stream: ${input.streamFilePath}`));
125
- }
126
- if (logPath) {
127
- print(chalk.gray(` Log: ${logPath}`));
128
- }
129
- print(chalk.gray(` Resume: cspeach --resume ${input.session.id}`));
130
- print(chalk.gray(` cspeach --resume (no arg → pick from a list)`));
168
+ const logPath = await writeErrorLog(id, input.err);
169
+ const detail = [reason, `Session: ${id}`];
170
+ if (input.streamFilePath)
171
+ detail.push(`Stream: ${input.streamFilePath}`);
172
+ if (logPath)
173
+ detail.push(`Log: ${logPath}`);
174
+ detail.push(...resumeRows);
175
+ printBlock(print, formatFailure({
176
+ what: 'Turn interrupted',
177
+ detail,
178
+ next: input.inSession
179
+ ? 'type continue to pick up where it stopped (your work so far is saved)'
180
+ : `cspeach --resume ${id}`,
181
+ }));
131
182
  print('');
132
183
  }
133
184
  /** Re-export for callers that want to compute a stream path independently. */
@@ -29,6 +29,17 @@ export function streamsRoot() {
29
29
  export class TurnStreamWriter {
30
30
  filePath;
31
31
  headerWritten = false;
32
+ /**
33
+ * Fix round 3 (item 11a) — every write runs after the previous one. The
34
+ * loop calls append() per text delta WITHOUT awaiting, so unserialised
35
+ * appendFile calls interleaved on disk (a scrambled log) and two racing
36
+ * first appends could each write the header.
37
+ */
38
+ chain = Promise.resolve();
39
+ enqueue(work) {
40
+ this.chain = this.chain.then(work, work);
41
+ return this.chain;
42
+ }
32
43
  constructor(sessionId, startedAt = new Date()) {
33
44
  // ISO timestamp with filename-safe punctuation.
34
45
  const stamp = startedAt.toISOString().replace(/[:.]/g, '-');
@@ -43,9 +54,12 @@ export class TurnStreamWriter {
43
54
  * file-IO errors so a write hiccup never kills a working turn. Lazily writes
44
55
  * a small header on the first append so an unused turn produces no file.
45
56
  */
46
- async append(text) {
57
+ append(text) {
47
58
  if (!text || text.length === 0)
48
- return;
59
+ return Promise.resolve();
60
+ return this.enqueue(() => this.write(text));
61
+ }
62
+ async write(text) {
49
63
  try {
50
64
  if (!this.headerWritten) {
51
65
  await fs.mkdir(path.dirname(this.filePath), { recursive: true });
@@ -63,7 +77,15 @@ export class TurnStreamWriter {
63
77
  * Close the stream with an optional footer (token totals, duration, end
64
78
  * status). Idempotent — safe to call multiple times.
65
79
  */
66
- async close(footer) {
80
+ close(footer) {
81
+ return this.enqueue(() => this.writeFooter(footer));
82
+ }
83
+ async writeFooter(footer) {
84
+ // Round 6 item 4e — a turn cut before it streamed any text (a cancel
85
+ // while thinking, /exit mid-turn) still gets a log with its footer, so
86
+ // the goodbye can point at it. A clean turn with no text writes nothing.
87
+ if (!this.headerWritten && footer)
88
+ await this.write('');
67
89
  if (!this.headerWritten)
68
90
  return;
69
91
  try {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Re-review item 3 — one normaliser for object types, shared by the syntax
3
+ * state (tools/syntax-state.ts) and the 8e risk-floor backstop. Approvals
4
+ * carry whatever type word the model chose ("CLASS", "Program", "PROG/P");
5
+ * the syntax state records the tool's ADT code. Unknown words return
6
+ * undefined so the caller can fall back to a name-only match.
7
+ */
8
+ const ALIASES = {
9
+ CLASS: 'CLAS', PROGRAM: 'PROG', REPORT: 'PROG', INTERFACE: 'INTF',
10
+ 'FUNCTION GROUP': 'FUGR', FUNCTIONGROUP: 'FUGR', 'FUNCTION MODULE': 'FUNC', FUNCTION: 'FUNC',
11
+ TABLE: 'TABL', STRUCTURE: 'TABL', 'DATA ELEMENT': 'DTEL', DOMAIN: 'DOMA', INCLUDE: 'PROG',
12
+ 'CDS VIEW': 'DDLS', CDS: 'DDLS', 'CDS VIEW ENTITY': 'DDLS', 'BEHAVIOR DEFINITION': 'BDEF', 'BEHAVIOUR DEFINITION': 'BDEF',
13
+ 'SERVICE DEFINITION': 'SRVD', 'SERVICE BINDING': 'SRVB', 'METADATA EXTENSION': 'DDLX', 'ACCESS CONTROL': 'DCLS',
14
+ 'MESSAGE CLASS': 'MSAG', PACKAGE: 'DEVC',
15
+ };
16
+ const CODES = new Set([
17
+ 'CLAS', 'PROG', 'INTF', 'FUGR', 'FUNC', 'TABL', 'DTEL', 'DOMA', 'DDLS', 'BDEF', 'SRVD', 'SRVB',
18
+ 'DDLX', 'DCLS', 'MSAG', 'DEVC', 'ENHO', 'XSLT', 'TTYP', 'VIEW', 'SHLP', 'NROB',
19
+ ]);
20
+ /** ADT code (CLAS, PROG, …) for a type word, or undefined when unknown. */
21
+ export function normaliseAdtType(type) {
22
+ const t = (type ?? '').trim().toUpperCase().replace(/\s+/g, ' ');
23
+ const head = t.split('/')[0].trim();
24
+ if (CODES.has(head))
25
+ return head;
26
+ return ALIASES[t] ?? ALIASES[head];
27
+ }
@@ -1,7 +1,55 @@
1
1
  import clipboardy from 'clipboardy';
2
2
  import readline from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
+ import { shouldUseInk } from '../renderer/tty.js';
5
+ import { emitTrace, formatDecisionTrace } from '../renderer/trace.js';
6
+ import { paint } from '../renderer/theme.js';
7
+ import { getGlyphs } from '../renderer/glyphs.js';
8
+ import { askConfirm } from '../ui/confirm-request.js';
9
+ const ADVISORY_LABEL = { applied: 'I applied it', skipped: 'Skip', aborted: 'Stop' };
10
+ /**
11
+ * Piece 2 §6.2 — Ink: the proposal goes to scrollback once; a confirm card
12
+ * holds the choices. `c` copies, shows "· Copied" and asks again (not a
13
+ * decision, as today). y/c/s/n and the words yes/skip/abort still work.
14
+ * Esc / no listener → 'aborted': stop, nothing is written. The cursor starts
15
+ * on Copy, so a bare Enter decides nothing (as a bare Enter does today).
16
+ */
17
+ async function promptAdvisoryInk(args) {
18
+ emitTrace(args.displayText);
19
+ for (;;) {
20
+ const res = await askConfirm({
21
+ id: 'advisory-proposal',
22
+ tag: 'ADVISORY',
23
+ title: 'Did you apply this change in ADT or SE80?',
24
+ facts: ['The proposal is shown above.', 'Nothing is written to SAP from here.'],
25
+ choices: [
26
+ { value: 'applied', label: ADVISORY_LABEL.applied },
27
+ { value: 'copy', label: 'Copy to clipboard' },
28
+ { value: 'skipped', label: ADVISORY_LABEL.skipped },
29
+ { value: 'aborted', label: ADVISORY_LABEL.aborted },
30
+ ],
31
+ initial: 1,
32
+ aliases: {
33
+ y: 'applied', yes: 'applied', applied: 'applied',
34
+ c: 'copy', copy: 'copy',
35
+ s: 'skipped', skip: 'skipped', skipped: 'skipped',
36
+ n: 'aborted', no: 'aborted', abort: 'aborted', aborted: 'aborted',
37
+ },
38
+ footnote: 'Advisory mode',
39
+ }, { trace: false });
40
+ if (res.value === 'copy') {
41
+ await clipboardy.write(args.clipboardText);
42
+ emitTrace(paint('dim', `${getGlyphs().info} Copied`));
43
+ continue;
44
+ }
45
+ const outcome = res.value === 'applied' ? 'applied' : res.value === 'skipped' ? 'skipped' : 'aborted';
46
+ emitTrace(formatDecisionTrace('Advisory proposal', ADVISORY_LABEL[outcome]));
47
+ return outcome;
48
+ }
49
+ }
4
50
  export async function promptAdvisory(args) {
51
+ if (shouldUseInk())
52
+ return promptAdvisoryInk(args);
5
53
  output.write(args.displayText + '\n');
6
54
  const rl = readline.createInterface({ input, output });
7
55
  try {
@@ -1,12 +1,25 @@
1
1
  import chalk from 'chalk';
2
- export function renderAdvisoryProposal(args) {
2
+ import { paint } from '../renderer/theme.js';
3
+ import { uiWidth } from '../renderer/ui-width.js';
4
+ import { sanitizeControlChars as clean } from '../renderer/sanitize.js';
5
+ const ADVISORY_TITLE = ' ADVISORY MODE — proposed change (no SAP write will occur) ';
6
+ export const ADVISORY_KEY_HINT = 'Press c to copy to clipboard, y when applied, s to skip, n to abort.';
7
+ export function renderAdvisoryProposal(args, opts = {}) {
8
+ // Model text is shown with control characters made harmless (it must not
9
+ // drive the terminal); the clipboard copy keeps it verbatim.
3
10
  const displayParts = [];
4
11
  const clipboardParts = [];
5
- displayParts.push(chalk.yellow.bold('╔══ ADVISORY MODE — proposed change (no SAP write will occur) ══╗'));
6
- displayParts.push(chalk.bold(`Summary: ${args.summary}`));
12
+ // Borders line up: top and bottom share one width (§1.4 rule, widened
13
+ // only if the title itself would not fit).
14
+ const w = Math.max(uiWidth(), ADVISORY_TITLE.length + 6);
15
+ const top = '╔══' + ADVISORY_TITLE + '═'.repeat(w - 4 - ADVISORY_TITLE.length) + '╗';
16
+ const bottom = '╚' + '═'.repeat(w - 2) + '╝';
17
+ const rule = paint('dim', '─'.repeat(w));
18
+ displayParts.push(chalk.bold(paint('warn', top)));
19
+ displayParts.push(chalk.bold(`Summary: ${clean(args.summary)}`));
7
20
  displayParts.push(`Risk: ${args.risk}`);
8
21
  if (args.transport)
9
- displayParts.push(`Target transport: ${args.transport}`);
22
+ displayParts.push(`Target transport: ${clean(args.transport)}`);
10
23
  displayParts.push('');
11
24
  clipboardParts.push(`# ${args.summary}`);
12
25
  if (args.transport)
@@ -14,21 +27,22 @@ export function renderAdvisoryProposal(args) {
14
27
  clipboardParts.push('');
15
28
  args.changes.forEach((change, idx) => {
16
29
  const header = `Change ${idx + 1}: ${change.op.toUpperCase()} ${change.type} ${change.object}`;
17
- displayParts.push(chalk.cyan(header));
30
+ displayParts.push(paint('sap', clean(header)));
18
31
  clipboardParts.push(`## ${header}`);
19
32
  if (change.diff) {
20
- displayParts.push(chalk.gray('─'.repeat(64)));
21
- displayParts.push(change.diff);
22
- displayParts.push(chalk.gray('─'.repeat(64)));
33
+ displayParts.push(rule);
34
+ displayParts.push(clean(change.diff));
35
+ displayParts.push(rule);
23
36
  clipboardParts.push('');
24
37
  clipboardParts.push(change.diff);
25
38
  clipboardParts.push('');
26
39
  }
27
40
  });
28
41
  displayParts.push('');
29
- displayParts.push(chalk.yellow('Next step: open this object in ADT or SE80, apply the change, save and activate.'));
30
- displayParts.push(chalk.gray('Press c to copy to clipboard, y when applied, s to skip, n to abort.'));
31
- displayParts.push(chalk.yellow.bold('╚══════════════════════════════════════════════════════════════════╝'));
42
+ displayParts.push(paint('warn', 'Next step: open this object in ADT or SE80, apply the change, save and activate.'));
43
+ if (opts.interactiveHint !== false)
44
+ displayParts.push(paint('dim', ADVISORY_KEY_HINT));
45
+ displayParts.push(chalk.bold(paint('warn', bottom)));
32
46
  clipboardParts.push('');
33
47
  clipboardParts.push('# This is an advisory proposal. Apply manually in ADT/SE80.');
34
48
  return {