@cspeach/cli 1.1.18 → 1.1.19

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 (224) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +58 -21
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. package/vitest.smoke-e2e.config.ts +20 -0
package/README.md CHANGED
@@ -79,12 +79,11 @@ On `/exit` you get a session summary + the `cspeach --resume <id>` command in pl
79
79
 
80
80
  ## UI modes
81
81
 
82
- Two REPL modes, both supported. **Default is `classic`.** Switch with `/ui ink` or `/ui classic`; saves to `~/.cspeach/config.toml` under `[ui]`.
82
+ An interactive terminal gets the standard display (the Ink frame). `/ui auto` (the default) picks it for you; `/ui ink` asks for it explicitly. Both save to `~/.cspeach/config.toml` under `[ui]` and apply after a relaunch.
83
83
 
84
- - **`classic`** — chalk + readline. The supported, demo-tested mode. Use this unless you have a reason not to. Native terminal scroll, mouse wheel, search — everything your terminal does naturally just works.
85
- - **`ink`** — React-based TUI. **PREVIEW.** Some rendering quirks remain (occasional phantom cursor, picker fossil in scrollback). Functional for daily use but not yet polished enough to recommend as default. Promoted to default in a later release.
84
+ One-shot runs (`cspeach "<prompt>"`), piped input or output, and CI get plain line output automatically.
86
85
 
87
- If `ink` is misbehaving for you, run `/ui classic` once — change applies after relaunch.
86
+ The classic display is retiring. A saved `ui.rendering = "classic"` is no longer used: the next interactive start switches it to `auto` and says so once. If you need classic for one launch, set the environment variable `CSPEACH_UI=classic` before starting `cspeach`.
88
87
 
89
88
  ## Configuration
90
89
 
@@ -8,9 +8,14 @@ import { loadConfig } from '../config/loader.js';
8
8
  import { resolveModelRole } from '../models/resolve.js';
9
9
  import { retryWithBackoff } from './retry.js';
10
10
  import { renderChunk, initialBufferState, render } from '../renderer/pipeline.js';
11
- import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner, isWidgetSuppressedTool } from '../renderer/tool-widget.js';
11
+ import { nextWritingActivity } from '../renderer/fence-state.js';
12
+ import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner, isWidgetSuppressedTool, toolActivityText, toolHeartbeatLabel } from '../renderer/tool-widget.js';
12
13
  import { formatToolErrorSummary } from '../sap-errors/parse-adt-exception.js';
13
14
  import { startThinkingHeartbeat } from '../renderer/thinking-heartbeat.js';
15
+ import { isDeclaredOneShot } from '../renderer/tty.js';
16
+ import { USER_STOP_REASON } from '../session/interrupt-reason.js';
17
+ import { writeStdout, stdoutFlow } from '../renderer/transcript-flow.js';
18
+ import { formatFailure, formatAttention, formatInfo } from '../renderer/notice-shape.js';
14
19
  // 2026-06-07 — status-row narration. Safe to import in every mode: the
15
20
  // emitter has zero listeners outside Ink (classic / one-shot / subagent),
16
21
  // so activity() calls are no-ops there.
@@ -25,6 +30,7 @@ import { globalStore } from '../ui/sap-state-store.js';
25
30
  import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
26
31
  import { enrichUserMessage } from '../router/intent-extractor.js';
27
32
  import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
33
+ import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
28
34
  import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
29
35
  import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
30
36
  import { input } from '@inquirer/prompts';
@@ -37,7 +43,7 @@ import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
37
43
  import { renderStandardsBlock } from '../standards/standards-file.js';
38
44
  import { composeSessionContextBlock } from './session-context.js';
39
45
  import { objectKeyFromInput } from './retry-key.js';
40
- import { repairPartialBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError } from './repair-partial.js';
46
+ import { repairPartialBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError, stripUnsignedThinking, isThinkingOnly, dropTrailingThinkingOnly } from './repair-partial.js';
41
47
  import { drainSteering } from './steering-queue.js';
42
48
  import { TurnStreamWriter } from './turn-stream.js';
43
49
  import { applyToolResultCheckpoint } from './skill-checkpoint.js';
@@ -155,9 +161,52 @@ export async function inkAwarePrompt(q) {
155
161
  }
156
162
  return withInquirer(() => input({ message: q }));
157
163
  }
164
+ /**
165
+ * Piece 2 / Q3 (spec §3 "[y/N] prompts") — the Yes/No question for the six
166
+ * [y/N] sites (via projects/yes-no.ts askYesNo). Returns 'y' | 'n' | ''.
167
+ *
168
+ * - headless → '' (never blocks), same log line as inkAwarePrompt. The save
169
+ * hook keeps its blanket 'y' by NOT passing this in headless (maybeOfferSave).
170
+ * - Ink → an AskCard Yes/No (v1 choice 'y'/'n') under the card-slot focus
171
+ * claim: cursor on the default, marked "(default)"; y/n keys answer at
172
+ * once; Esc / no listener / anything else → '' (today's
173
+ * `result.answer ?? ''`). Leaves one decision trace in scrollback.
174
+ * - classic → inquirer input with today's exact "<q> [y/N]: " text.
175
+ */
176
+ export async function inkAwareConfirm(q, opts) {
177
+ const { shouldUseInk, isHeadless } = await import('../renderer/tty.js');
178
+ if (isHeadless()) {
179
+ console.error(chalk.dim(`headless: skipped prompt '${q.trim()}' → '' (default)`));
180
+ return '';
181
+ }
182
+ if (shouldUseInk()) {
183
+ const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
184
+ const { withFocusClaim } = await import('../ui/card-slot.js');
185
+ const { emitTrace, formatDecisionTrace } = await import('../renderer/trace.js');
186
+ const { uiWidth } = await import('../renderer/ui-width.js');
187
+ const res = await withFocusClaim(() => askQuestionEmitter.request({
188
+ id: 'agent-confirm',
189
+ question: q,
190
+ kind: 'choice',
191
+ choices: [{ value: 'y', label: 'Yes' }, { value: 'n', label: 'No' }],
192
+ defaultValue: opts.default,
193
+ keys: { y: 'y', n: 'n' },
194
+ }));
195
+ const answer = res.answer === 'y' || res.answer === 'n' ? res.answer : '';
196
+ emitTrace(formatDecisionTrace(q, answer === 'y' ? 'Yes' : answer === 'n' ? 'No' : 'cancelled', uiWidth()));
197
+ return answer;
198
+ }
199
+ const hint = opts.default === 'y' ? '[Y/n]' : '[y/N]';
200
+ const raw = (await withInquirer(() => input({ message: `${q} ${hint}: ` }))).trim().toLowerCase();
201
+ if (raw === 'y' || raw === 'yes')
202
+ return 'y';
203
+ if (raw === '')
204
+ return opts.default;
205
+ return 'n';
206
+ }
158
207
  /**
159
208
  * Choices picker for an answer-blocker that carries `options`. Renders the
160
- * native Ink AskQuestionModal (the same CC-style picker the ask_question tool
209
+ * native Ink AskCard (the same CC-style picker the ask_question tool
161
210
  * uses) with the candidate answers plus "Write my own" and "Skip" escapes.
162
211
  *
163
212
  * - headless → skip (never blocks on stdin)
@@ -220,7 +269,7 @@ export async function maybeOfferSave(p) {
220
269
  if (!detected)
221
270
  return;
222
271
  saveSkill = detected;
223
- p.emit(chalk.dim(`[save] ${detected} artifact manifest detected in this turn's output (turn was labelled '${p.skill}') — offering save.`));
272
+ p.emit(formatInfo(`${detected} output found in this turn (routed as ${p.skill}) · offering to save it`).trimEnd());
224
273
  }
225
274
  // B5 (2026-06-11) — defect D2: in headless runs the artifact IS the point
226
275
  // of the run, so the "Save as project file? [y/N]" prompt defaults YES
@@ -249,6 +298,9 @@ export async function maybeOfferSave(p) {
249
298
  author: getAuthorIdentity(),
250
299
  cwd: process.cwd(),
251
300
  prompt,
301
+ // Piece 2 / Q3 — interactive saves ask a Yes/No card; headless passes
302
+ // no confirm so the blanket-'y' prompt above still answers (B5 contract).
303
+ confirm: isHeadless() ? undefined : inkAwareConfirm,
252
304
  log: (...lines) => lines.forEach((l) => p.emit(l)),
253
305
  promotedFrom: p.promotedFrom ?? null,
254
306
  });
@@ -262,17 +314,36 @@ export async function maybeOfferSave(p) {
262
314
  path: savedPath,
263
315
  identity: getAuthorIdentity(),
264
316
  prompt: inkAwarePrompt,
317
+ confirm: inkAwareConfirm,
265
318
  choose: inkAwareChoose,
266
319
  log: (...lines) => lines.forEach((l) => p.emit(l)),
267
320
  });
268
321
  }
269
322
  }
270
323
  catch (err) {
271
- p.emit(chalk.yellow(`\n[save] skipped: ${err?.message ?? err}`));
324
+ p.emit('\n' + formatAttention({ text: 'Not saved as a project file', fact: String(err?.message ?? err) }).trimEnd());
272
325
  }
273
326
  }
274
327
  export async function runTurn(params) {
275
328
  resetRule8State(); // Rule 8 — fresh batch counter per LLM turn (= per user prompt)
329
+ // Piece 2 / P2 — fresh "plan changed this turn" flag per TOP-LEVEL turn.
330
+ // Not every path ends in pushPostTurnStatus (the Ink /reroute turn does
331
+ // not), so a mark left by such a turn must not earn the NEXT turn a close
332
+ // line. Subagent ctxs omit todoEmitter: a child turn keeps the parent's mark.
333
+ if (params.ctx.todoEmitter)
334
+ consumeTodoSetThisTurn();
335
+ // Panels spacing (polish P1): the REPL printed the prompt etc. outside the
336
+ // stdout transcript flow, so a stdout turn starts from "fresh line".
337
+ if (!params.chunkEmitter)
338
+ stdoutFlow.reset();
339
+ // Fix round 7 item 2 — one-shot passes its answer emitter (stdout, trimmed)
340
+ // as chunkEmitter. Liveness (spinners, heartbeats) must never enter the
341
+ // answer: in one-shot it keeps its own sinks (stderr heartbeat, in-place
342
+ // spinner only on a real terminal) exactly as before.
343
+ const livenessEmitter = isDeclaredOneShot() ? undefined : params.chunkEmitter;
344
+ // Owner ruling (r3 M11) — in one-shot the tool rows go with the tool
345
+ // notices (ctx.chunkEmitter → stderr), never into the answer on stdout.
346
+ const toolRowEmitter = isDeclaredOneShot() ? (params.ctx.chunkEmitter ?? params.chunkEmitter) : params.chunkEmitter;
276
347
  const cfg = await loadConfig();
277
348
  const session = params.ctx.session;
278
349
  // Define `emit` early so the Phase J promote dispatch (below) can stream
@@ -311,6 +382,7 @@ export async function runTurn(params) {
311
382
  sourcePath: fromPath,
312
383
  targetSkill: params.skill,
313
384
  prompt: inkAwarePrompt,
385
+ confirm: inkAwareConfirm,
314
386
  log: (...lines) => lines.forEach((l) => emit(l)),
315
387
  });
316
388
  if (!result) {
@@ -385,7 +457,12 @@ ${imageExpansion.error}`));
385
457
  // user has no standards file, so the composed block (and therefore the whole
386
458
  // prompt) stays byte-identical to pre-feature output for existing users.
387
459
  const standardsBlock = await renderStandardsBlock(params.ctx.cwd);
388
- const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock]);
460
+ // Round 6 review I4 — a resume moved this session to another system: tell
461
+ // the model once, on this turn, then drop the note.
462
+ const switchNote = session.systemSwitchNote
463
+ ? `<system_switch>${session.systemSwitchNote}</system_switch>`
464
+ : '';
465
+ const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock, switchNote]);
389
466
  // Deterministically extract structured facts from the user's prompt
390
467
  // (package, transport, object names, environment hint, create intent)
391
468
  // and prepend them as an XML `<session_context>` block. The model reads
@@ -398,9 +475,12 @@ ${imageExpansion.error}`));
398
475
  // here, which would defeat a "first turn only" check. Skip only when the
399
476
  // message already begins with <session_context> (caller enriched it) or
400
477
  // when extraction yields nothing (enrichUserMessage returns original).
478
+ // Final batch 1 — a caller-enriched message still gets the switch note,
479
+ // inside its own <session_context> block.
401
480
  const enriched = userMessageForLLM.startsWith('<session_context>')
402
- ? userMessageForLLM
481
+ ? (switchNote ? userMessageForLLM.replace('<session_context>', `<session_context>\n ${switchNote}`) : userMessageForLLM)
403
482
  : enrichUserMessage(userMessageForLLM, sessionContextBlock);
483
+ const switchNoteUsed = switchNote !== '' && enriched.includes(switchNote);
404
484
  // Phase 2 of Track B — opt-in project-context enrichment, gated by
405
485
  // CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
406
486
  // identical to today. When on, the rendered <project_context> block
@@ -434,6 +514,10 @@ ${imageExpansion.error}`));
434
514
  // Images (if any) ride as blocks ahead of the text; with none this is the
435
515
  // same plain string as before.
436
516
  session.messages.push({ role: 'user', content: buildUserContent(finalUserContent, imageExpansion.images) });
517
+ // Round 6 review I4 / final batch 1 — the switch note is dropped only once
518
+ // it is in a pushed user message.
519
+ if (switchNoteUsed)
520
+ delete session.systemSwitchNote;
437
521
  if (imageExpansion.images.length > 0 && params.provider.name === 'local-openai-compat') {
438
522
  // The local (OpenAI-compatible) provider keeps text blocks only.
439
523
  emit(chalk.dim('(local model: the image is not sent — the model gets the text only)'));
@@ -513,6 +597,7 @@ ${imageExpansion.error}`));
513
597
  }
514
598
  }
515
599
  let buffer = initialBufferState();
600
+ let writingCode = false; // piece 2 §11.2 — "writing code…" while a fence is held back
516
601
  let stream;
517
602
  // 2026-05-06: paint a "thinking" spinner during the LLM round-trip
518
603
  // wait so the user sees activity instead of staring at a silent
@@ -531,7 +616,7 @@ ${imageExpansion.error}`));
531
616
  // keeps the stdout spinner unchanged.
532
617
  // Guard: test/unit/ink-cursor-invariant.test.ts.
533
618
  turnStatusEmitter.activity('thinking');
534
- const thinkingSpinner = startThinkingSpinner({ chunkEmitter: params.chunkEmitter });
619
+ const thinkingSpinner = startThinkingSpinner({ chunkEmitter: livenessEmitter });
535
620
  // v0.6 — guaranteed-visible heartbeat alongside the in-place spinner.
536
621
  // The spinner self-disables on non-TTY (Windows PowerShell sometimes
537
622
  // reports isTTY=false; resume mode also lost spinner visibility) so a
@@ -541,7 +626,7 @@ ${imageExpansion.error}`));
541
626
  // heartbeat lines land in the Body region (via React) instead of
542
627
  // being raw-written to stdout (which Ink overdraws on its next
543
628
  // render, briefly flashing the line then making it disappear).
544
- const thinkingHeartbeat = startThinkingHeartbeat({ chunkEmitter: params.chunkEmitter });
629
+ const thinkingHeartbeat = startThinkingHeartbeat({ chunkEmitter: livenessEmitter });
545
630
  try {
546
631
  stream = await retryWithBackoff(() => (async () => {
547
632
  // W2.5 — apply optional tool filter (used by agent_run with
@@ -565,6 +650,12 @@ ${imageExpansion.error}`));
565
650
  // the single chokepoint every request passes through, retries included —
566
651
  // makes the 400 categorically impossible. Idempotent and O(messages).
567
652
  healSessionMessagesInPlace(session.messages);
653
+ // 2026-09-27 — same chokepoint: a thinking block without its
654
+ // signature (a turn cancelled mid-thinking, saved by an older CLI)
655
+ // 400s every later request with "Invalid `signature` in `thinking`
656
+ // block". Drop it; the model simply thinks again.
657
+ stripUnsignedThinking(session.messages);
658
+ dropTrailingThinkingOnly(session.messages);
568
659
  // 2026-09-24 — same chokepoint: a tool call cut off before its
569
660
  // result was recorded 400s every later request (a customer's
570
661
  // `continue` could never succeed). Give each orphan an error result
@@ -621,7 +712,11 @@ ${imageExpansion.error}`));
621
712
  if (isPartialJsonApiError(err)) {
622
713
  const scrubbed = healSessionMessagesInPlace(session.messages);
623
714
  await saveSession(session);
624
- emit(chalk.yellow(`\n⚠ Session was poisoned by a partial-json tool_use block; healed in-memory${scrubbed > 0 ? ` (${scrubbed} block${scrubbed === 1 ? '' : 's'} cleaned)` : ''}. Please retry your last message.`));
715
+ emit('\n' + formatAttention({
716
+ text: 'Session repaired',
717
+ fact: `a broken tool call was cleaned up${scrubbed > 0 ? ` (${scrubbed} block${scrubbed === 1 ? '' : 's'})` : ''}`,
718
+ next: 'send your last message again',
719
+ }).trimEnd());
625
720
  return;
626
721
  }
627
722
  // Proxy returns 409 upgrade_required when CLI is older than skill's min_cli_version.
@@ -629,13 +724,19 @@ ${imageExpansion.error}`));
629
724
  const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
630
725
  const body = typeof bodyRaw === 'string' ? JSON.parse(bodyRaw) : bodyRaw;
631
726
  if (status === 409 && body?.error === 'upgrade_required') {
632
- emit(chalk.yellow.bold(`\n⚠ This skill requires cspeach >= ${body.min_cli_version} (you have ${body.current ?? 'unknown'}).`));
633
- emit(chalk.yellow(` Run: cspeach --upgrade`));
727
+ emit('\n' + formatFailure({
728
+ what: 'This skill needs a newer CSPeach',
729
+ detail: [`needs ${body.min_cli_version} or later · you have ${body.current ?? 'unknown'}`],
730
+ next: 'cspeach --upgrade',
731
+ }).trimEnd());
634
732
  return;
635
733
  }
636
734
  throw err;
637
735
  }
638
736
  let currentAssistantContent = [];
737
+ // 2026-09-27 — blocks whose content_block_stop arrived. A thinking block
738
+ // not in here was cut mid-stream and must never be persisted.
739
+ const completedBlocks = new WeakSet();
639
740
  let sawEndTurn = false;
640
741
  let sawToolUse = false;
641
742
  let sawMaxTokens = false;
@@ -682,6 +783,7 @@ ${imageExpansion.error}`));
682
783
  // silent if the renderer buffers the block — the row keeps
683
784
  // ticking "writing response · 90s" regardless.
684
785
  turnStatusEmitter.activity('writing response');
786
+ writingCode = false;
685
787
  const block = event.content_block;
686
788
  currentAssistantContent.push(block);
687
789
  if (block.type === 'text') {
@@ -689,7 +791,7 @@ ${imageExpansion.error}`));
689
791
  if (params.chunkEmitter)
690
792
  params.chunkEmitter.emit('chunk', sep);
691
793
  else
692
- process.stdout.write(sep);
794
+ writeStdout(sep);
693
795
  }
694
796
  }
695
797
  else if (type === 'content_block_delta') {
@@ -698,11 +800,15 @@ ${imageExpansion.error}`));
698
800
  if (delta.type === 'text_delta') {
699
801
  const { output, state } = renderChunk(delta.text, buffer);
700
802
  buffer = state;
803
+ const wa = nextWritingActivity(writingCode, buffer.pending);
804
+ writingCode = wa.writingCode;
805
+ if (wa.label)
806
+ turnStatusEmitter.activity(wa.label);
701
807
  if (output) {
702
808
  if (params.chunkEmitter)
703
809
  params.chunkEmitter.emit('chunk', output);
704
810
  else
705
- process.stdout.write(output);
811
+ writeStdout(output);
706
812
  }
707
813
  if (last?.type === 'text')
708
814
  last.text = (last.text ?? '') + delta.text;
@@ -730,6 +836,8 @@ ${imageExpansion.error}`));
730
836
  }
731
837
  else if (type === 'content_block_stop') {
732
838
  const last = currentAssistantContent[currentAssistantContent.length - 1];
839
+ if (last && typeof last === 'object')
840
+ completedBlocks.add(last);
733
841
  if (last?.type === 'tool_use' && last.partial_json) {
734
842
  try {
735
843
  last.input = JSON.parse(last.partial_json);
@@ -804,8 +912,9 @@ ${imageExpansion.error}`));
804
912
  if (params.chunkEmitter)
805
913
  params.chunkEmitter.emit('chunk', flushed);
806
914
  else
807
- process.stdout.write(flushed);
915
+ writeStdout(flushed);
808
916
  buffer = initialBufferState();
917
+ writingCode = false;
809
918
  }
810
919
  // Repair partial blocks before persistence. ALWAYS run, not just on
811
920
  // interruption: on a clean tool-use round the model's tool_use blocks can
@@ -815,7 +924,11 @@ ${imageExpansion.error}`));
815
924
  // only DROPS a block when its `input` is undefined — which never happens on
816
925
  // a clean round (content_block_stop always sets `input`), so this is a
817
926
  // no-op-except-strip on the happy path. See ./repair-partial.ts.
818
- currentAssistantContent = repairPartialBlocks(currentAssistantContent);
927
+ currentAssistantContent = repairPartialBlocks(currentAssistantContent, { completed: completedBlocks });
928
+ // Review M2 — a cut right after the thinking block leaves a thinking-only
929
+ // turn: nothing the user can read, and nothing the next request needs.
930
+ if (isThinkingOnly(currentAssistantContent))
931
+ currentAssistantContent = [];
819
932
  // Persist the assistant message — even if it's partial. Empty content
820
933
  // arrays are skipped so we don't push a meaningless empty assistant
821
934
  // message that would confuse the next round.
@@ -842,7 +955,9 @@ ${imageExpansion.error}`));
842
955
  if (interruptedError !== null) {
843
956
  session.turnInterrupted = true;
844
957
  const msg = interruptedError instanceof Error ? interruptedError.message : String(interruptedError);
845
- session.turnInterruptedReason = msg.slice(0, 200);
958
+ // Round 7 item 8d — a stop the user asked for (Ctrl+C, /exit mid-turn)
959
+ // is saved as such, never as the raw "Request was aborted.".
960
+ session.turnInterruptedReason = params.signal?.aborted ? USER_STOP_REASON : msg.slice(0, 200);
846
961
  }
847
962
  else {
848
963
  // Clean completion — clear any stale interrupted flag from a prior turn.
@@ -884,8 +999,9 @@ ${imageExpansion.error}`));
884
999
  void appendCostLine(session.id, entry);
885
1000
  }
886
1001
  // Close the stream-to-disk mirror with a footer (lightweight diagnostics).
887
- // Best-effort — failure here is invisible to the turn flow.
888
- void turnStreamWriter.close(interruptedError !== null
1002
+ // Best-effort — failure here is invisible to the turn flow. Awaited
1003
+ // (round 6 item 4e) so an exit right after the turn finds the log.
1004
+ await turnStreamWriter.close(interruptedError !== null
889
1005
  ? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
890
1006
  : undefined);
891
1007
  // Re-throw AFTER persistence so the outer REPL catch can render the
@@ -1038,7 +1154,7 @@ ${imageExpansion.error}`));
1038
1154
  renderToolCallTop({
1039
1155
  name: block.name,
1040
1156
  args: (block.input ?? {}),
1041
- chunkEmitter: params.chunkEmitter,
1157
+ chunkEmitter: toolRowEmitter,
1042
1158
  });
1043
1159
  // Phase 2a: start the peach-themed spinner. No-op outside TTY / Ink
1044
1160
  // / CI — the result line still prints, just without the in-place
@@ -1048,7 +1164,7 @@ ${imageExpansion.error}`));
1048
1164
  // orphan on its own row above the form.
1049
1165
  const spinner = isWidgetSuppressedTool(block.name)
1050
1166
  ? { stop: () => undefined }
1051
- : startToolSpinner({ chunkEmitter: params.chunkEmitter });
1167
+ : startToolSpinner({ chunkEmitter: livenessEmitter, label: toolActivityText(block.name, (block.input ?? {})) });
1052
1168
  // 2026-06-06 (turn-liveness, B5 smoke feedback) — the in-place tool
1053
1169
  // spinner self-disables under Ink (phantom cursor #25), which left
1054
1170
  // slow tool calls (SAP over VPN: 10-60s) as DEAD AIR between the ⏺
@@ -1087,12 +1203,12 @@ ${imageExpansion.error}`));
1087
1203
  if (isInteractiveTool)
1088
1204
  turnStatusEmitter.pause();
1089
1205
  else
1090
- turnStatusEmitter.activity(block.name);
1206
+ turnStatusEmitter.activity(toolActivityText(block.name, (block.input ?? {})));
1091
1207
  const toolHeartbeat = isInteractiveTool
1092
1208
  ? { stop: () => undefined }
1093
1209
  : startThinkingHeartbeat({
1094
- chunkEmitter: params.chunkEmitter,
1095
- label: `${block.name} running…`,
1210
+ chunkEmitter: livenessEmitter,
1211
+ label: toolHeartbeatLabel(block.name, (block.input ?? {})),
1096
1212
  });
1097
1213
  // Phase 2b: dispatch (may take 100ms–several seconds for write tools).
1098
1214
  const dispatchStart = Date.now();
@@ -1139,13 +1255,15 @@ ${imageExpansion.error}`));
1139
1255
  durationMs,
1140
1256
  isError: result.is_error ?? false,
1141
1257
  resultSummary,
1142
- chunkEmitter: params.chunkEmitter,
1258
+ chunkEmitter: toolRowEmitter,
1143
1259
  // D29 (2026-06-12): self-identifying result row. Heartbeat lines,
1144
1260
  // sap-client warns, and notice lines legitimately print between
1145
1261
  // the ⏺ top line and this row — without the name here those rows
1146
1262
  // read as anonymous `⎿ ✓ 364ms` orphans in the transcript.
1147
1263
  name: block.name,
1148
1264
  args: (block.input ?? {}),
1265
+ // Panels look: counts ("17 lines") and folded command output.
1266
+ resultContent: typeof result.content === 'string' ? result.content : undefined,
1149
1267
  });
1150
1268
  // v0.3 (Step 28.0): populate session.toolCalls so the SessionTimeline
1151
1269
  // overlay can render per-write history. Cap per-entry payload size at
@@ -1,4 +1,5 @@
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';
@@ -19,6 +20,7 @@ export function createLocalProvider(cfg) {
19
20
  if (params.tools && params.tools.length > 0) {
20
21
  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
22
  }
23
+ const OpenAI = await loadOpenAI();
22
24
  const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
23
25
  const openAiParams = anthropicParamsToOpenAI(params, model);
24
26
  // stream: true widens via object spread; the literal-true overload cannot be
@@ -29,6 +31,7 @@ export function createLocalProvider(cfg) {
29
31
  },
30
32
  async healthCheck() {
31
33
  try {
34
+ const OpenAI = await loadOpenAI();
32
35
  const sdk = new OpenAI({ apiKey: 'local-ignored', baseURL });
33
36
  await sdk.chat.completions.create({
34
37
  model,
@@ -1,23 +1,19 @@
1
1
  /**
2
- * Repair partial assistant content blocks before persisting them to a session.
3
- *
4
- * When a provider stream is interrupted mid-flight (HTTP socket dropped,
5
- * AbortController fired, network blip), the agent loop's accumulated
6
- * `currentAssistantContent` may include `tool_use` blocks whose `input` JSON
7
- * never finished streaming — typically the SDK has buffered the bytes into
8
- * `partial_json` but `JSON.parse` was never called, so `input` is undefined.
9
- *
10
- * Persisting an assistant message that contains an incomplete tool_use block
11
- * causes the next API call to reject the message with:
12
- * 400 each `tool_use` block must contain `input` (or similar)
13
- *
14
- * Drop incomplete tool_use blocks. The model will re-emit them on the next
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.
2
+ * 2026-09-27 — a thinking block is only valid to send back to the API with its
3
+ * complete signature. The signature arrives last (`signature_delta`, right
4
+ * before `content_block_stop`), so a turn cancelled mid-thinking leaves a block
5
+ * with `signature: ''` — and every later request 400s with
6
+ * messages.N.content.M: Invalid `signature` in `thinking` block
7
+ * A redacted_thinking block is likewise only valid with its `data`.
19
8
  */
20
- export function repairPartialBlocks(blocks) {
9
+ export function isUnsignedThinking(b) {
10
+ if (b?.type === 'thinking')
11
+ return typeof b.signature !== 'string' || b.signature.length === 0;
12
+ if (b?.type === 'redacted_thinking')
13
+ return typeof b.data !== 'string' || b.data.length === 0;
14
+ return false;
15
+ }
16
+ export function repairPartialBlocks(blocks, opts = {}) {
21
17
  return blocks.filter((b) => {
22
18
  if (b == null || typeof b !== 'object')
23
19
  return false;
@@ -35,10 +31,46 @@ export function repairPartialBlocks(blocks) {
35
31
  delete b.partial_json;
36
32
  return true;
37
33
  }
38
- // text, thinking, redacted_thinking, etc. — keep as-is.
34
+ if (b.type === 'thinking' || b.type === 'redacted_thinking') {
35
+ // Never persist a partial thinking block: no signature, or cut before
36
+ // its content_block_stop. The model re-thinks on the next round.
37
+ if (isUnsignedThinking(b))
38
+ return false;
39
+ if (opts.completed && !opts.completed.has(b))
40
+ return false;
41
+ return true;
42
+ }
43
+ // text etc. — keep as-is (partial prose is still useful and valid).
39
44
  return true;
40
45
  });
41
46
  }
47
+ /**
48
+ * Heal a session already poisoned by an unsigned thinking block (saved by a
49
+ * CLI before 2026-09-27): drop every thinking / redacted_thinking block that
50
+ * lacks its signature, and drop a message left with no content (the API
51
+ * rejects empty content; consecutive user turns are merged by the API).
52
+ * Mutates `messages`; returns the number of blocks removed. Idempotent.
53
+ */
54
+ export function stripUnsignedThinking(messages) {
55
+ if (!messages)
56
+ return 0;
57
+ let removed = 0;
58
+ for (let i = messages.length - 1; i >= 0; i--) {
59
+ const msg = messages[i];
60
+ if (!Array.isArray(msg?.content))
61
+ continue;
62
+ const kept = msg.content.filter((b) => !isUnsignedThinking(b));
63
+ const dropped = msg.content.length - kept.length;
64
+ if (dropped === 0)
65
+ continue;
66
+ removed += dropped;
67
+ if (kept.length === 0)
68
+ messages.splice(i, 1);
69
+ else
70
+ msg.content = kept;
71
+ }
72
+ return removed;
73
+ }
42
74
  /**
43
75
  * In-memory heal for a poisoned session.messages array.
44
76
  *
@@ -167,3 +199,28 @@ export function healOrphanToolUses(messages) {
167
199
  }
168
200
  return added;
169
201
  }
202
+ /** Review M2 — true when every block is thinking / redacted_thinking (and there is one). */
203
+ export function isThinkingOnly(blocks) {
204
+ return Array.isArray(blocks) && blocks.length > 0
205
+ && blocks.every((b) => b?.type === 'thinking' || b?.type === 'redacted_thinking');
206
+ }
207
+ /**
208
+ * Review M2 — a turn cut right after a signed thinking block (before any
209
+ * text or tool call) left an assistant message holding only thinking. Drop
210
+ * it when it is the LAST assistant message; earlier turns are never touched
211
+ * (removing a block mid-transcript would invalidate later signed blocks).
212
+ * Mutates `messages`; returns the number of messages removed. Idempotent.
213
+ */
214
+ export function dropTrailingThinkingOnly(messages) {
215
+ if (!messages)
216
+ return 0;
217
+ for (let i = messages.length - 1; i >= 0; i--) {
218
+ if (messages[i]?.role !== 'assistant')
219
+ continue;
220
+ if (!isThinkingOnly(messages[i].content))
221
+ return 0;
222
+ messages.splice(i, 1);
223
+ return 1;
224
+ }
225
+ return 0;
226
+ }