@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
package/dist/repl.js CHANGED
@@ -6,37 +6,60 @@ import chalk from 'chalk';
6
6
  // callback-module's InterfaceConstructor signature or import from
7
7
  // 'node:readline' directly for that specific operation.
8
8
  import * as readline from 'node:readline/promises';
9
- import { select, password as promptPassword } from '@inquirer/prompts';
10
- import { loadConfig, resolveLocalBuild, resolveStandalone } from './config/loader.js';
9
+ import { select, password as promptPassword } from './repl/themed-prompts.js';
10
+ import { loadConfig, resolveLocalBuild, resolveStandalone, DEFAULT_CONFIG } from './config/loader.js';
11
+ import { getLook, setLook, setBackground } from './renderer/look.js';
12
+ import { applyColorMode } from './renderer/color-mode.js';
13
+ import { startEarlyBrand, aliasSourceNote, panelsHintLine, panelsResumeLine, panelsStartTail, panelsInterruptedNote, formatPromptEcho } from './renderer/startup-lines.js';
14
+ import { formatAttention, formatFailure, formatInfo } from './renderer/notice-shape.js';
15
+ import { formatPanel } from './renderer/panel-rows.js';
16
+ import { sanitizeControlChars } from './renderer/sanitize.js';
17
+ import { formatRoutingLine } from './renderer/routing-line.js';
18
+ import { handleUiLook, handleUiRendering } from './repl/ui-look-command.js';
19
+ import { CLI_VERSION } from './lib/version.js';
11
20
  import { createOfflineAdtClient } from './sap/offline-adt-client.js';
12
21
  import { STANDALONE_ALIAS } from './tools/index.js';
22
+ import { resumeStandaloneDecision, applySystemSwitch } from './repl/resume-standalone.js';
23
+ import { applyResumedSkill, isShortFollowUp } from './router/resume-routing.js';
24
+ import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
13
25
  import { getStore } from './auth/api-key.js';
14
26
  import { readAuthFile } from './auth/auth-file.js';
15
27
  import { fetchMe } from './auth/me.js';
16
- import { formatLoginBanner } from './ui/login-banner.js';
17
- import { printStartupBanner, printKeytarFallbackBanner } from './renderer/banners.js';
28
+ import { formatLoginBanner, signInFragment } from './ui/login-banner.js';
29
+ import { printStartupBanner, printKeytarFallbackBanner, keychainFallbackNotice } from './renderer/banners.js';
30
+ import { paint } from './renderer/theme.js';
31
+ import { steeringEchoLine, classifyMidTurnLine, midTurnSlashHint } from './renderer/steering-echo.js';
18
32
  import { runAddSapWizard } from './sap/onboarding.js';
19
33
  import { getConnection } from './sap/connection-manager.js';
34
+ import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
20
35
  import { AdtClient, snapshots } from '@cspeach/sap-client';
21
- import { makeSessionId, saveSession } from './session/store.js';
36
+ import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
22
37
  import { newSession } from './session/schema.js';
23
38
  import { resolveModelRole } from './models/resolve.js';
24
- import { loadServerModelConfig } from './models/server-config.js';
39
+ import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
40
+ import { repinResumedSessionModel } from './session/repin-model.js';
25
41
  import { computeCost } from './cost/pricing.js';
26
- import { formatSessionSpendLine } from './repl/session-spend-line.js';
42
+ import { formatSessionSpendValue } from './repl/session-spend-line.js';
43
+ import { formatGoodbye } from './renderer/goodbye.js';
27
44
  import { globalCredits } from './cost/credits.js';
28
45
  import { initCredits } from './cost/credits-wire.js';
29
46
  import { createProviderForMode } from './agent/providers/factory.js';
30
47
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
31
48
  import { runTurn } from './agent/loop.js';
49
+ import { IdleKeepalive } from './agent/cache-keepalive.js';
32
50
  import { renderTurnError } from './agent/turn-error-ux.js';
51
+ import { runRerouteTurn } from './repl/reroute-turn.js';
33
52
  // BUG-2b (2026-07-05 live smoke) — pure (no-Ink) modules, safe to import
34
53
  // statically even though App itself is lazy-loaded to keep Ink out of
35
54
  // classic mode. Used by the single-flight dispatch guard below.
36
55
  import { turnStatusEmitter } from './ui/turn-status-emitter.js';
56
+ import { decideInterrupt, createPendingTurnCancel, pendingCancelLine, queuedWorkLabel, autoDispatchDecision, settleWithin, EXIT_TAIL_CAP_MS } from './ui/interrupt.js';
57
+ import { setTraceEmitter } from './renderer/trace.js';
37
58
  import { enqueueSteering } from './agent/steering-queue.js';
38
59
  import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
60
+ import { pipedRoutingPick } from './router/piped-routing.js';
39
61
  import { decideRouting } from './router/routing-decision.js';
62
+ import { routingFailureFromError, routingFailureFromResult, routingFailureNotice, routingFailureOutcome, routingFailureText } from './router/routing-failure.js';
40
63
  import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
41
64
  import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
42
65
  import { globalStore as sapStateStore } from './ui/sap-state-store.js';
@@ -51,6 +74,9 @@ import { approvalEmitter } from './ui/approval-emitter.js';
51
74
  import { collectRewindCandidates } from './rewind/candidates.js';
52
75
  import { executeRestore, runClassicRewind } from './rewind/cli.js';
53
76
  import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
77
+ import { pipeRevivedStdin } from './repl/reset-tty-stdin.js';
78
+ import { logsDir } from './config/paths.js';
79
+ import { noticeLogPathFor, pruneNoticeLogs, setNoticeLogPath } from './renderer/notice-log.js';
54
80
  import { maybePromptWorkspaceMigration, maybeOfferAbapforgeMigration } from './projects/migration.js';
55
81
  import { findProjectRoot } from './projects/workspace.js';
56
82
  import { slashCompleter } from './repl/slash-completer.js';
@@ -81,19 +107,26 @@ function prefillReadline(rl, text) {
81
107
  import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
82
108
  import { InkStdinPasteGuard } from './repl/ink-stdin-guard.js';
83
109
  import { printHelp } from './commands/help.js';
84
- import { shouldUseInk, setRenderingOverride } from './renderer/tty.js';
110
+ import { shouldUseInk, setOneShotOverride } from './renderer/tty.js';
111
+ import { applyStartupRendering } from './renderer/rendering-mode.js';
85
112
  import { createClassicOutputEmitter } from './renderer/notices.js';
86
113
  import { saveConfig } from './config/loader.js';
87
114
  import { EventEmitter } from 'node:events';
115
+ import { existsSync } from 'node:fs';
116
+ import { join } from 'node:path';
117
+ import { streamsRoot } from './agent/turn-stream.js';
88
118
  import { parseAndValidateReroute } from './commands/reroute.js';
119
+ import { emitPlanTurnEnd } from './repl/plan-turn-end.js';
89
120
  import { SKILL_CATALOG } from './skill-catalog.js';
90
- import { replPreviewHook } from './repl/update-method-preview-hook.js';
121
+ import { replPreviewHook, inkPreviewHook } from './repl/update-method-preview-hook.js';
91
122
  import { getCurrentTransport, handleTransportCommand } from './repl/current-transport.js';
92
123
  import { matchSafetyModeCommand } from './repl/safety-mode-state.js';
93
124
  import { getEffectiveWriteMode, runModeCommand } from './repl/mode-cycle.js';
94
125
  import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-menu.js';
95
126
  import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
96
- import { snapshotTurnStart, pushPostTurnStatus } from './repl/post-turn-status.js';
127
+ import { createRestoreGuard } from './repl/restore-guard.js';
128
+ import { gracefulExit } from './lib/graceful-exit.js';
129
+ import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
97
130
  import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
98
131
  import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
99
132
  // Side-effect imports so tool registrations happen before listTools() is called.
@@ -106,6 +139,8 @@ import './tools/ask-question.js';
106
139
  import './tools/dispatch-skill.js';
107
140
  import './tools/todo.js';
108
141
  import './tools/filesystem/file-read.js';
142
+ import './tools/tool-output-read.js';
143
+ import { pruneSpillFiles } from './tools/result-spill.js';
109
144
  import './tools/filesystem/read-document.js';
110
145
  import './tools/filesystem/file-edit.js';
111
146
  import './tools/filesystem/file-write.js';
@@ -127,7 +162,7 @@ import './tools/subagent/background_run.js';
127
162
  import './tools/subagent/monitor_emit.js';
128
163
  import './tools/subagent/schedule_draft_create.js';
129
164
  import './tools/subagent/agent_run.js';
130
- const PEACH = '#FFCBA4';
165
+ import './tools/subagent/read_agent.js';
131
166
  const HISTORY_SIZE = 500;
132
167
  // Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
133
168
  // the single writer shared with the Ink Footer (Task 8, ux-wave1).
@@ -228,6 +263,15 @@ function isAckContinuation(trimmed, sessionSkill) {
228
263
  * shell shows only the startup banner + a bare "session saved" line,
229
264
  * which feels like everything they just did vanished.
230
265
  */
266
+ /** `~/.cspeach/streams/<id>/` when that folder exists, else null (fix round 3, item 11b). */
267
+ function existingStreamDir(sessionId) {
268
+ try {
269
+ return existsSync(join(streamsRoot(), sessionId)) ? `~/.cspeach/streams/${sessionId}/` : null;
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ }
231
275
  function printGoodbye(session) {
232
276
  const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
233
277
  const sessionCost = computeCost(session.model, {
@@ -240,16 +284,18 @@ function printGoodbye(session) {
240
284
  (session.usage.output_tokens ?? 0) +
241
285
  (session.usage.cache_read_input_tokens ?? 0) +
242
286
  (session.usage.cache_creation_input_tokens ?? 0);
243
- console.log('');
244
- console.log(chalk.bold(`Session ${session.id}`));
245
- console.log(chalk.dim(` turns: ${userTurns}`));
246
- console.log(chalk.dim(` last skill: ${session.skill ?? '(none)'}`));
247
- console.log(chalk.dim(` tokens: ${totalTokens.toLocaleString()} (input+output+cache)`));
248
- console.log(chalk.dim(formatSessionSpendLine(sessionCost, globalCredits.snapshot())));
249
- console.log('');
250
- console.log(chalk.cyan(`Resume with: cspeach --resume ${session.id}`));
251
- console.log(chalk.dim(`Full log: ~/.cspeach/streams/${session.id}.md`));
252
- console.log('');
287
+ for (const line of formatGoodbye({
288
+ sessionId: session.id,
289
+ turns: userTurns,
290
+ lastSkill: session.skill ?? null,
291
+ tokens: totalTokens,
292
+ spend: formatSessionSpendValue(sessionCost, globalCredits.snapshot()),
293
+ logDir: existingStreamDir(session.id),
294
+ // Round 6 review I1 — gate on the file: a start-then-quit (or a double
295
+ // Ctrl+C in the first turn before it saved) has no session to resume.
296
+ saved: sessionSaved(session),
297
+ }))
298
+ console.log(line);
253
299
  }
254
300
  function isBareAck(trimmed) {
255
301
  const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
@@ -308,7 +354,29 @@ function buildCoachingParams(result, prompt, preSelect, lastSkill) {
308
354
  };
309
355
  }
310
356
  export async function runRepl(opts) {
311
- printStartupBanner();
357
+ applyColorMode(); // NO_COLOR (piece 2 §1.2) — idempotent; main() already did it for the CLI path
358
+ // 2026-09-26 — the look must be known before the first startup line prints.
359
+ // A config read failure keeps the default look; the real loadConfig below
360
+ // still runs and reports errors exactly as before.
361
+ try {
362
+ const early = await loadConfig();
363
+ setLook(early.ui.look);
364
+ setBackground(early.ui.background);
365
+ }
366
+ catch {
367
+ setLook(DEFAULT_CONFIG.ui.look);
368
+ }
369
+ // Piece 2 §10: the account folds into the panels brand line; "!" notices
370
+ // collect here and print last. Owner decision 2026-09-27: the panels brand
371
+ // line prints FIRST (no blank terminal during the sign-in check and the
372
+ // rest of startup); the sign-in completes the same line below. Legacy keeps
373
+ // its pinned banner exactly as before.
374
+ const startupNotices = [];
375
+ if (getLook() !== 'panels')
376
+ printStartupBanner();
377
+ const earlyBrand = getLook() === 'panels'
378
+ ? startEarlyBrand({ version: CLI_VERSION, tty: process.stdout.isTTY === true, write: (s) => { process.stdout.write(s); } })
379
+ : undefined;
312
380
  // v0.6 — surface interrupted sessions at startup so the developer doesn't
313
381
  // have to remember the session id from the crash UX. Like `git stash list`
314
382
  // for resilience: if you have any unfinished work, you see it immediately.
@@ -318,8 +386,10 @@ export async function runRepl(opts) {
318
386
  const { countInterruptedSessions } = await import('./session/store.js');
319
387
  const n = await countInterruptedSessions();
320
388
  if (n > 0) {
321
- console.log(chalk.yellow.bold(`⚠ You have ${n} interrupted session${n === 1 ? '' : 's'}.`));
322
- console.log(chalk.yellow(` Recover with: cspeach --resume (picks the right one from a list)\n`));
389
+ if (getLook() === 'panels')
390
+ startupNotices.push(panelsInterruptedNote(n) + '\n');
391
+ else
392
+ console.log(formatAttention({ text: `You have ${n} interrupted session${n === 1 ? '' : 's'}`, next: 'cspeach --resume (picks the right one from a list)' }));
323
393
  }
324
394
  }
325
395
  catch { /* startup banner is best-effort */ }
@@ -331,7 +401,7 @@ export async function runRepl(opts) {
331
401
  // flag is process-scoped, so this prints at most once per CLI launch.
332
402
  try {
333
403
  const { isFlagOn } = await import('./agent/maybe-build-project-context.js');
334
- if (isFlagOn()) {
404
+ if (isFlagOn() && getLook() !== 'panels') {
335
405
  console.log(chalk.cyan(`[project-context] enabled (cwd=${process.cwd()})`));
336
406
  console.log(chalk.dim(' unset CSPEACH_PROJECT_CONTEXT to disable; run `cspeach analyze project-context` later to compare.\n'));
337
407
  }
@@ -341,6 +411,7 @@ export async function runRepl(opts) {
341
411
  // and render the verbose login banner. Falls through to the legacy keytar
342
412
  // flow below when the file is absent (back-compat for pre-`cspeach login`
343
413
  // users).
414
+ let signIn;
344
415
  try {
345
416
  const authFile = await readAuthFile();
346
417
  if (authFile) {
@@ -348,25 +419,44 @@ export async function runRepl(opts) {
348
419
  const meResult = await fetchMe({ proxyUrl: cfgEarly.proxy_url, apiKey: authFile.apiKey });
349
420
  if (!meResult.ok) {
350
421
  if (meResult.status === 401) {
351
- console.log(chalk.yellow('Your CSPeach session is invalid or expired. Run `cspeach login` to re-authenticate.'));
422
+ earlyBrand?.close();
423
+ console.log(formatFailure({ what: 'Your CSPeach sign-in is no longer valid', next: 'cspeach login' }));
352
424
  return 1;
353
425
  }
354
- console.log(chalk.dim(`(could not refresh /v1/me — ${meResult.error})`));
426
+ const note = formatInfo(`Could not refresh your account (${meResult.error})`);
427
+ if (getLook() === 'panels')
428
+ startupNotices.push(note);
429
+ else
430
+ console.log(note.trimEnd());
355
431
  }
356
432
  else {
357
433
  const banner = formatLoginBanner(meResult.profile);
358
- console.log(banner.line);
359
- if (banner.warningLine)
360
- console.log(chalk.yellow(banner.warningLine));
434
+ if (getLook() === 'panels') {
435
+ signIn = signInFragment(meResult.profile);
436
+ if (banner.warningLine)
437
+ startupNotices.push(formatAttention({ text: banner.warningLine }));
438
+ }
439
+ else {
440
+ console.log(banner.line);
441
+ if (banner.warningLine)
442
+ console.log(formatAttention({ text: banner.warningLine }).trimEnd());
443
+ }
361
444
  }
362
445
  }
363
446
  }
364
447
  catch { /* banner is best-effort — never block REPL startup */ }
448
+ earlyBrand?.finish(signIn);
365
449
  const store = await getStore();
366
- if (!(await store.isKeychainAvailable())) {
367
- printKeytarFallbackBanner();
368
- }
450
+ const keychainMissing = !(await store.isKeychainAvailable());
369
451
  let apiKey = await store.get();
452
+ if (keychainMissing) {
453
+ // Panels: a "!" line with the other notices — unless the API key prompt is
454
+ // about to open, which needs the explanation right now.
455
+ if (getLook() === 'panels' && apiKey)
456
+ startupNotices.push(keychainFallbackNotice());
457
+ else
458
+ printKeytarFallbackBanner();
459
+ }
370
460
  if (!apiKey) {
371
461
  const entered = await promptPassword({
372
462
  message: 'Paste your CSPeach API key (from invite email):',
@@ -376,19 +466,40 @@ export async function runRepl(opts) {
376
466
  apiKey = entered;
377
467
  }
378
468
  let cfg = await loadConfig();
379
- // Apply user-configured rendering override BEFORE any shouldUseInk() call.
380
- setRenderingOverride(cfg.ui.rendering);
469
+ setLook(cfg.ui.look);
470
+ // Apply the rendering mode BEFORE any shouldUseInk() call. This is the
471
+ // interactive entry point, so it is never one-shot whatever argv holds. A
472
+ // stored `classic` is retiring: read as auto, rewritten once, one dim notice
473
+ // (CSPEACH_UI=classic is the escape hatch) — see renderer/rendering-mode.ts.
474
+ setOneShotOverride(false);
475
+ const classicRetiringNotice = await applyStartupRendering(cfg, saveConfig);
476
+ if (classicRetiringNotice) {
477
+ if (getLook() === 'panels')
478
+ startupNotices.push(classicRetiringNotice);
479
+ else
480
+ process.stdout.write(classicRetiringNotice);
481
+ }
482
+ setBackground(cfg.ui.background);
381
483
  // Standalone (2026-09-16) — run with no SAP system at all. Decided BEFORE
382
484
  // the onboarding wizard: someone who asked for --no-sap (or persisted
383
485
  // no_sap) must never be pushed through the SAP setup they don't have.
384
486
  // Having aliases configured does NOT override the request.
385
487
  // A session that WAS standalone stays standalone on `--resume`: its
386
488
  // sap_system is the reserved 'none' alias, which has no entry in cfg.sap.
387
- let standalone = resolveStandalone(cfg, opts?.noSap === true) ||
388
- opts?.resumedSession?.sap_system === STANDALONE_ALIAS;
489
+ // Round 6 item 2 — …unless an explicit --sap-alias asks for a SAP system:
490
+ // then the flag wins and the resumed session moves to it.
491
+ const standaloneDecision = resumeStandaloneDecision({
492
+ requestedStandalone: resolveStandalone(cfg, opts?.noSap === true),
493
+ resumedSapSystem: opts?.resumedSession?.sap_system,
494
+ sapAlias: opts?.sapAlias,
495
+ standaloneSource: opts?.noSap === true ? '--no-sap' : 'no_sap setting',
496
+ });
497
+ let standalone = standaloneDecision.standalone;
389
498
  const enterStandalone = async () => {
390
- console.log(chalk.dim('\nStandalone mode: no SAP system connected — skills work from pasted code. ' +
391
- 'Connect later with: cspeach config add-sap'));
499
+ if (getLook() !== 'panels') {
500
+ console.log(chalk.dim('\nStandalone mode: no SAP system connected — skills work from pasted code. ' +
501
+ 'Connect later with: cspeach config add-sap'));
502
+ }
392
503
  // Ask the profile questions ONCE — they become the <sap_system> element
393
504
  // every skill reads, so without them a design would target "unknown".
394
505
  if (!cfg.standalone) {
@@ -421,11 +532,22 @@ export async function runRepl(opts) {
421
532
  cfg = await loadConfig();
422
533
  }
423
534
  }
424
- // model-governance step 2d — fire-and-forget fetch of the served model-config
425
- // (managed mode only). NEVER blocks startup: a turn that starts before the
426
- // fetch lands simply uses the built-ins (fail-safe). Precedence when it does
427
- // land: env > local config > server > built-in.
428
- void loadServerModelConfig(cfg);
535
+ // model-governance step 2d — fetch the served model-config (managed mode
536
+ // only). Task 3 (2026-09-27): AWAITED, but startup waits at most 1.5 s — a
537
+ // slower fetch aborts and falls back to a fresh disk cache, else the
538
+ // built-ins. The session model is resolved ONCE at newSession below and
539
+ // pinned, so call 0 and call 1 never run on different models (a switch
540
+ // forces a full cache rewrite). Precedence: env > local config > server >
541
+ // built-in. Never throws.
542
+ // Task 12 — the customer layer (/v1/me/model-config, every mode with a
543
+ // key) loads in parallel under the SAME 1.5 s cap: no extra wait. Final
544
+ // precedence: env > local > customer > server > built-in. Fails open.
545
+ await Promise.all([
546
+ loadServerModelConfig(cfg, { timeoutMs: 1500 }),
547
+ loadCustomerModelConfig(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
548
+ // Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
549
+ fetchToolLoadingMode(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
550
+ ]);
429
551
  // Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
430
552
  // A managed customer is billed in credits at a multiplier we do not
431
553
  // disclose, so the dollar figures (our Anthropic COGS) must never reach
@@ -444,12 +566,20 @@ export async function runRepl(opts) {
444
566
  // identity, so print here exactly as before (byte-identical output); when
445
567
  // any alias declares a role, defer the banner until the alias is resolved.
446
568
  const anyRoleConfigured = Object.values(cfg.sap).some((s) => s.role !== undefined);
569
+ const advisoryNotice = (capped) => formatAttention({
570
+ text: 'Write mode: advisory',
571
+ fact: `writes are proposed, never executed${capped}`,
572
+ // The config hint only makes sense when config CAUSED advisory.
573
+ next: capped === '' ? 'cspeach config set write_mode approval-gated' : undefined,
574
+ });
447
575
  if (!anyRoleConfigured) {
448
576
  if (cfg.write_mode === 'advisory-only') {
449
- console.log(chalk.yellow.bold('⚠ Write mode: ADVISORY (writes proposed, never executed)'));
450
- console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
577
+ if (getLook() === 'panels')
578
+ startupNotices.push(advisoryNotice(''));
579
+ else
580
+ console.log(advisoryNotice(''));
451
581
  }
452
- else {
582
+ else if (getLook() !== 'panels') {
453
583
  console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
454
584
  }
455
585
  }
@@ -460,15 +590,28 @@ export async function runRepl(opts) {
460
590
  // visibly: the toggle is persisted, not re-consented each session, so the user
461
591
  // must see that local file access + command execution are live this session.
462
592
  // Printed to stdout here (before any Ink frame mounts).
593
+ // 2026-09-26 — panels folds this announce into the one status line below.
594
+ // 2026-09-26 (I1) — the panels status line must say WHICH tools are live:
595
+ // local_build = file tools + shell command execution; standalone alone =
596
+ // file tools writing to ./cspeach-out.
597
+ let localTools = 'none';
463
598
  {
464
599
  const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
465
- if (announce)
466
- console.log(chalk.dim(announce + '\n'));
467
- // 2026-09-17 — standalone writes its outputs to files by contract, so the
468
- // file tools must be on without a separate local_build step (the RAP
469
- // stack landed in the transcript with "There are no files" otherwise).
470
- else if (standalone)
471
- console.log(chalk.dim(applyStandaloneFileTools(process.cwd()) + '\n'));
600
+ localTools = announce ? 'files+commands' : standalone ? 'files' : 'none';
601
+ if (getLook() === 'panels') {
602
+ // The side effect (turning the file tools on) must still happen.
603
+ if (!announce && standalone)
604
+ applyStandaloneFileTools(process.cwd());
605
+ }
606
+ else {
607
+ if (announce)
608
+ console.log(chalk.dim(announce + '\n'));
609
+ // 2026-09-17 — standalone writes its outputs to files by contract, so the
610
+ // file tools must be on without a separate local_build step (the RAP
611
+ // stack landed in the transcript with "There are no files" otherwise).
612
+ else if (standalone)
613
+ console.log(chalk.dim(applyStandaloneFileTools(process.cwd()) + '\n'));
614
+ }
472
615
  }
473
616
  const aliases = Object.keys(cfg.sap);
474
617
  // C.1 — --sap-alias flag override skips the interactive picker
@@ -479,13 +622,17 @@ export async function runRepl(opts) {
479
622
  selectedAlias = STANDALONE_ALIAS;
480
623
  }
481
624
  else if (opts?.sapAlias) {
482
- console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
625
+ const note = aliasSourceNote('flag', opts.sapAlias);
626
+ if (note !== null)
627
+ console.log(note);
483
628
  selectedAlias = opts.sapAlias;
484
629
  }
485
630
  else if (opts?.resumedSession?.sap_system) {
486
631
  // v0.6 — resumed session knows its SAP. Skip the picker.
487
632
  selectedAlias = opts.resumedSession.sap_system;
488
- console.log(chalk.dim(`Resumed on SAP ${selectedAlias}`));
633
+ const note = aliasSourceNote('resume', selectedAlias);
634
+ if (note !== null)
635
+ console.log(note);
489
636
  }
490
637
  else {
491
638
  selectedAlias =
@@ -507,25 +654,35 @@ export async function runRepl(opts) {
507
654
  ? ` (capped by sap.${selectedAlias}.role = ${aliasRole})`
508
655
  : '';
509
656
  if (startupWriteMode === 'advisory-only') {
510
- console.log(chalk.yellow.bold(`⚠ Write mode: ADVISORY (writes proposed, never executed)${capped}`));
511
- // The config hint only makes sense when config CAUSED advisory —
512
- // a role-capped advisory can't be lifted by a config change.
513
- if (capped === '') {
514
- console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
515
- }
516
- else {
517
- console.log('');
518
- }
657
+ // A role-capped advisory can't be lifted by a config change (no Next:).
658
+ if (getLook() === 'panels')
659
+ startupNotices.push(advisoryNotice(capped));
660
+ else
661
+ console.log(advisoryNotice(capped));
519
662
  }
520
- else {
663
+ else if (getLook() !== 'panels') {
521
664
  console.log(chalk.gray(`Write mode: ${startupWriteMode}${capped}\n`));
522
665
  }
523
666
  }
524
667
  // Standalone: the offline stand-in rejects every call with an actionable
525
668
  // message. This is the single construction site the AdtClient cast lives at.
526
- const adt = standalone
527
- ? createOfflineAdtClient()
528
- : new AdtClient(await getConnection(selectedAlias, cfg.sap[selectedAlias]));
669
+ let adt;
670
+ if (standalone) {
671
+ adt = createOfflineAdtClient();
672
+ }
673
+ else {
674
+ try {
675
+ adt = new AdtClient(await getConnection(selectedAlias, cfg.sap[selectedAlias]));
676
+ }
677
+ catch (err) {
678
+ // Dry run 2026-09-25: unreachable SAP host → one clear line, not a stack.
679
+ const code = unreachableCode(err);
680
+ if (!code)
681
+ throw err;
682
+ console.error(unreachableMessage(selectedAlias, cfg.sap[selectedAlias].host, cfg.sap[selectedAlias].port, code));
683
+ return 1;
684
+ }
685
+ }
529
686
  // Phase A #3 (2026-05-16) — sidebar SAP-state wire, trigger 1 of 4.
530
687
  // Push system + user the moment we know the alias. Before this commit
531
688
  // the sidebar showed "no system / user: —" forever because the store
@@ -541,15 +698,49 @@ export async function runRepl(opts) {
541
698
  system: standalone ? 'standalone' : selectedAlias,
542
699
  user: standalone ? '' : cfg.sap[selectedAlias].username,
543
700
  writeMode: startupWriteMode,
701
+ standalone,
544
702
  });
545
703
  // v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
546
704
  // preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
547
705
  // turnInterrupted flag from the prior run. Skipping newSession() avoids
548
706
  // overwriting the saved file with an empty fresh one.
707
+ // Task 3 fix I1 — except the model: a resumed session re-resolves it ONCE
708
+ // through the same role resolution new sessions use (after the awaited
709
+ // served config), marks it 'predicted', saves, then stays pinned. Every
710
+ // resume entry (picker, --resume <id>) lands here via session/resume.ts.
549
711
  const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg));
550
- if (!opts?.resumedSession) {
551
- await saveSession(session);
712
+ // Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
713
+ let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
714
+ // Round 7 item 8c — the footer shows a resumed session's spend at once.
715
+ if (opts?.resumedSession)
716
+ seedFooterSpend(session);
717
+ // Round 6 item 4b — a new session is written by its first turn (the loop
718
+ // saves after every round); a start-then-quit no longer leaves a 0-turn file.
719
+ // Round 6 item 2 + review I3 — an explicit --sap-alias moved a resumed
720
+ // session (standalone → SAP, or to another SAP system): update + save it.
721
+ await applySystemSwitch({
722
+ session,
723
+ decision: { ...standaloneDecision, standalone },
724
+ selectedAlias,
725
+ save: saveSession,
726
+ notify: (line) => {
727
+ if (getLook() === 'panels')
728
+ startupNotices.push(line);
729
+ else
730
+ console.log(line.trimEnd());
731
+ },
732
+ });
733
+ // Merge (cost branch Task 3 + UI round 6) — a resumed session re-pins its
734
+ // model from the current config (after any --sap-alias switch above).
735
+ if (opts?.resumedSession) {
736
+ await repinResumedSessionModel(session, cfg);
552
737
  }
738
+ // Live fix 2026-09-26 — notices cut to one screen line keep their full text here.
739
+ // Review M6: sanitised id; notice logs older than 14 days are collected.
740
+ pruneNoticeLogs(logsDir());
741
+ // Task 17 — saved large tool outputs (tool-*.txt) older than 14 days go too.
742
+ pruneSpillFiles();
743
+ setNoticeLogPath(noticeLogPathFor(logsDir(), session.id));
553
744
  // License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
554
745
  // inference, so without this anyone who never logged in could use the bundled
555
746
  // skills untracked. Require a valid CSPeach login before the REPL starts.
@@ -569,30 +760,70 @@ export async function runRepl(opts) {
569
760
  throw new Error('No API key configured');
570
761
  return key;
571
762
  });
763
+ // Task 15 — keep the prompt cache warm while the user is idle between
764
+ // turns: armed after a turn, disarmed on the next submit and on exit;
765
+ // `[keepalive] idle_pings` per idle period (managed / BYOK only).
766
+ const idleKeepalive = new IdleKeepalive({
767
+ provider,
768
+ maxPings: () => (cfg.keepalive?.enabled === false ? 0 : (cfg.keepalive?.idle_pings ?? 4)),
769
+ });
770
+ // Panels status line — writeMode is the same EFFECTIVE (role-clamped) mode
771
+ // the legacy line prints.
772
+ const panelsStatus = {
773
+ system: selectedAlias,
774
+ standalone,
775
+ tools: localTools,
776
+ writeMode: startupWriteMode,
777
+ cwd: process.cwd(),
778
+ };
572
779
  if (opts?.resumedSession) {
573
780
  const turnCount = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
574
- console.log(chalk.cyan(`\nResumed session ${session.id} on ${selectedAlias} — ${turnCount} prior turn(s).`));
575
- if (session.turnInterrupted) {
576
- const reason = session.turnInterruptedReason ?? 'unknown';
577
- console.log(chalk.yellow(`⚠ Last turn was interrupted (${reason}).`));
578
- console.log(chalk.yellow(` Type \"continue\" to ask the model to finish where it left off.`));
579
- }
580
- // Task 6 (ux-wave2) — session recap: three dim lines (last session ·
581
- // open todos · unsatisfied plan) so the returning developer is oriented
582
- // without scrolling. Covers BOTH resume entries — the `cspeach --resume`
583
- // picker (session/resume.ts) and any direct --resume <id> — since both
584
- // land here with opts.resumedSession set. Best-effort: buildRecap never
585
- // throws, and a workspace-resolution failure just drops the plan line.
586
- try {
587
- const { buildRecap } = await import('./session/recap.js');
588
- const { resolveWorkspacePath } = await import('./projects/workspace.js');
589
- const recapWs = await resolveWorkspacePath().catch(() => undefined);
590
- for (const recapLine of buildRecap(session, process.cwd(), { workspaceDir: recapWs })) {
591
- console.log(chalk.dim(recapLine));
781
+ // Task 6 (ux-wave2) — session recap (last session · open todos ·
782
+ // unsatisfied plan) so the returning developer is oriented without
783
+ // scrolling. Covers BOTH resume entries — the `cspeach --resume` picker
784
+ // (session/resume.ts) and any direct --resume <id> — since both land here
785
+ // with opts.resumedSession set. Best-effort: the recap never throws, and a
786
+ // workspace-resolution failure just drops the plan fact.
787
+ if (getLook() === 'panels') {
788
+ let recap = { last: null, tasks: null, plan: null };
789
+ try {
790
+ const { buildRecapParts } = await import('./session/recap.js');
791
+ const { resolveWorkspacePath } = await import('./projects/workspace.js');
792
+ const recapWs = await resolveWorkspacePath().catch(() => undefined);
793
+ recap = buildRecapParts(session, process.cwd(), { workspaceDir: recapWs });
794
+ }
795
+ catch { /* recap is best-effort — never block resume */ }
796
+ // Piece 2 §10: same three-line shape. Open tasks show in the checklist
797
+ // (restored from session.todos), so only the plan becomes a "!" line.
798
+ if (session.turnInterrupted) {
799
+ // Round 7 item 8d — a stop the user asked for is not an error.
800
+ startupNotices.push(isUserStopReason(session.turnInterruptedReason)
801
+ ? formatAttention({ text: 'Last turn was stopped by you', next: 'type continue to finish where it left off' })
802
+ : formatAttention({ text: 'Last turn was interrupted', fact: readableInterruptReason(session.turnInterruptedReason), next: 'type continue to finish where it left off' }));
803
+ }
804
+ if (recap.plan)
805
+ startupNotices.push(formatAttention({ text: `Unfinished plan ${recap.plan.text}`, next: recap.plan.next }));
806
+ process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsResumeLine(recap.last, turnCount), notices: startupNotices }));
807
+ }
808
+ else {
809
+ console.log(chalk.cyan(`\nResumed session ${session.id} on ${selectedAlias} — ${turnCount} prior turn(s).`));
810
+ if (session.turnInterrupted) {
811
+ console.log(formatAttention({ text: isUserStopReason(session.turnInterruptedReason) ? 'Last turn was stopped by you' : `Last turn was interrupted (${readableInterruptReason(session.turnInterruptedReason)})`, next: 'type "continue" to ask the model to finish where it left off' }).trimEnd());
812
+ }
813
+ try {
814
+ const { buildRecap } = await import('./session/recap.js');
815
+ const { resolveWorkspacePath } = await import('./projects/workspace.js');
816
+ const recapWs = await resolveWorkspacePath().catch(() => undefined);
817
+ for (const recapLine of buildRecap(session, process.cwd(), { workspaceDir: recapWs })) {
818
+ console.log(chalk.dim(recapLine));
819
+ }
592
820
  }
821
+ catch { /* recap is best-effort — never block resume */ }
822
+ console.log(chalk.gray(`Type /exit to quit.\n`));
593
823
  }
594
- catch { /* recap is best-effort — never block resume */ }
595
- console.log(chalk.gray(`Type /exit to quit.\n`));
824
+ }
825
+ else if (getLook() === 'panels') {
826
+ process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools), notices: startupNotices }));
596
827
  }
597
828
  else {
598
829
  console.log(chalk.gray(`\nSession ${session.id} on ${selectedAlias}. Type /exit to quit.\n`));
@@ -625,6 +856,8 @@ export async function runRepl(opts) {
625
856
  const { App } = await import('./ui/app.js');
626
857
  const React = (await import('react')).default;
627
858
  const chunkEmitter = new EventEmitter();
859
+ setTraceEmitter(chunkEmitter); // piece 2 §1.5 — answered/decided traces land in <Static>
860
+ const inkPreview = inkPreviewHook(chunkEmitter); // piece 2 §6.3 — Ink sites: diff to scrollback + Apply/Cancel card
628
861
  // Phase D6 (2026-05-17) — INLINE Ink mode (no alt-screen).
629
862
  //
630
863
  // After Laeeq pointed out CC achieves great scrolling without any
@@ -674,6 +907,50 @@ export async function runRepl(opts) {
674
907
  // it. When null, we're at idle and should exit on Ctrl+C.
675
908
  let currentTurnAbort = null;
676
909
  let interruptCount = 0;
910
+ // Review I2/I3 — a cancel asked for before the turn has its controller
911
+ // (routing / prep). Applied at controller creation; cleared when the
912
+ // dispatch ends without a turn.
913
+ const pendingCancel = createPendingTurnCancel();
914
+ // Re-review 2/3 — where a busy dispatch without a controller is, so the
915
+ // cancel line says only what will actually happen. postTurn: the turn's
916
+ // controller has been cleared (saving, audit, next-phase offer);
917
+ // postTurnMayChain: that post-turn work may auto-start a plan phase.
918
+ let postTurn = false;
919
+ let postTurnMayChain = false;
920
+ let cancelAnnouncedPhase = null;
921
+ /** Round 4 item 5 — the in-flight classifier request, while routing. */
922
+ let routingAbort = null;
923
+ const cancelPhase = () => turnStatusEmitter.snapshot().routing ? 'routing' : postTurn ? 'post' : 'prep';
924
+ const canChainNow = () => postTurnMayChain || pendingDispatchAccessor.get() !== null;
925
+ /** Queue a cancel; print its one truthful line the first time. */
926
+ const announcePendingCancel = (exiting) => {
927
+ if (!pendingCancel.request())
928
+ return;
929
+ const phase = cancelPhase();
930
+ cancelAnnouncedPhase = phase;
931
+ // Round 4 item 5 — stop the classifier request (and its spinner) now.
932
+ routingAbort?.abort();
933
+ const line = phase === 'post' && !canChainNow()
934
+ ? 'Exiting when this turn has finished saving'
935
+ // Round 4 item 9a — name what is queued (a skill switch is not a plan phase).
936
+ : pendingCancelLine(phase, exiting, queuedWorkLabel(pendingDispatchAccessor.get()));
937
+ chunkEmitter.emit('chunk', '\n' + formatInfo(line));
938
+ };
939
+ /** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
940
+ const autoDispatch = (cmd) => {
941
+ // Round 4 item 9b — the exit is already under way: start nothing new.
942
+ // Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
943
+ const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending }, cmd);
944
+ if (decision.kind === 'skip')
945
+ return;
946
+ if (decision.kind === 'not-started') {
947
+ pendingCancel.clear();
948
+ chunkEmitter.emit('chunk', formatInfo(decision.line));
949
+ return;
950
+ }
951
+ chunkEmitter.emit('chunk', formatInfo(`Auto-routed: ${cmd}`));
952
+ setImmediate(() => { void handleSubmit(cmd); });
953
+ };
677
954
  // BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
678
955
  // owns the single-flight reserve()/release() lifecycle. All re-entrant
679
956
  // callers (the setImmediate auto-dispatch / re-audit legs) go through the
@@ -682,6 +959,10 @@ export async function runRepl(opts) {
682
959
  const trimmed = line.trim();
683
960
  if (!trimmed)
684
961
  return;
962
+ // Round 6 review I2 — the resumed skill is offered to the FIRST submit
963
+ // only (slash commands included), then cleared.
964
+ const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
965
+ resumeKeepsSkill = false;
685
966
  // Task 8 (ux-wave1) — shared dedup-guarded writer. On the Ink path
686
967
  // the Footer has already persisted this submission (flattened), so
687
968
  // the consecutive-dup guard makes this a no-op instead of the old
@@ -694,8 +975,7 @@ export async function runRepl(opts) {
694
975
  // new turn's content touched with no separator and looked like
695
976
  // garbled overlap).
696
977
  if (trimmed !== '/exit' && trimmed !== '/quit') {
697
- chunkEmitter.emit('chunk', chalk.dim('\n──────────────────────────────────\n') +
698
- chalk.cyan(`› ${trimmed}\n`));
978
+ chunkEmitter.emit('chunk', formatPromptEcho(trimmed, process.stdout.columns || 80));
699
979
  }
700
980
  if (trimmed === '/exit' || trimmed === '/quit') {
701
981
  done = true;
@@ -719,7 +999,9 @@ export async function runRepl(opts) {
719
999
  // from the JSONL log written each turn. Read-only, no network.
720
1000
  if (trimmed === '/cost') {
721
1001
  const { runCostCommand } = await import('./commands/cost.js');
722
- await runCostCommand(session.id);
1002
+ // Piece 2: through the chunkEmitter (like /context and /export) — a
1003
+ // console.log here would land above the Ink frame.
1004
+ await runCostCommand(session.id, undefined, (l) => chunkEmitter.emit('chunk', l + '\n'));
723
1005
  return;
724
1006
  }
725
1007
  // Task 4 (ux-wave3) — /export audit [filename]: write the signable
@@ -764,7 +1046,7 @@ export async function runRepl(opts) {
764
1046
  if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
765
1047
  const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
766
1048
  const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
767
- chunkEmitter.emit('chunk', chalk.dim(modeLine) + '\n');
1049
+ chunkEmitter.emit('chunk', formatInfo(modeLine));
768
1050
  sapStateStore.update({ writeMode: mode });
769
1051
  return;
770
1052
  }
@@ -777,8 +1059,8 @@ export async function runRepl(opts) {
777
1059
  const recapWs = await resolveWorkspacePath().catch(() => undefined);
778
1060
  const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
779
1061
  const body = recapLines.length === 0
780
- ? chalk.dim('nothing to recap yet')
781
- : recapLines.map((l) => chalk.dim(l)).join('\n');
1062
+ ? formatInfo('Nothing to recap yet').trimEnd()
1063
+ : formatPanel('Recap', recapLines.map((l) => ' ' + paint('dim', sanitizeControlChars(l)))).join('\n');
782
1064
  chunkEmitter.emit('chunk', '\n' + body + '\n');
783
1065
  return;
784
1066
  }
@@ -794,15 +1076,15 @@ export async function runRepl(opts) {
794
1076
  return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
795
1077
  // model-governance step 2d — compact model resolves env > local >
796
1078
  // server > built-in (== COMPACTION_MODEL when nothing set).
797
- { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
1079
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
798
1080
  };
799
1081
  try {
800
- chunkEmitter.emit('chunk', chalk.dim('\nCompacting older turns into a summary...\n'));
1082
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Compacting older turns into a summary…'));
801
1083
  const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
802
1084
  chunkEmitter.emit('chunk', formatCompactResult(result, compactCfg.keep_recent_turns));
803
1085
  }
804
1086
  catch (err) {
805
- chunkEmitter.emit('chunk', chalk.red(`\n/compact failed: ${err instanceof Error ? err.message : String(err)}\n`));
1087
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Compact failed', detail: [err instanceof Error ? err.message : String(err)] }));
806
1088
  }
807
1089
  return;
808
1090
  }
@@ -812,9 +1094,9 @@ export async function runRepl(opts) {
812
1094
  if (trimmed === '/files') {
813
1095
  try {
814
1096
  const { openFilePicker } = await import('./repl/file-picker.js');
815
- const result = await openFilePicker();
1097
+ const result = await openFilePicker({ ink: true });
816
1098
  if (result.kind === 'command') {
817
- chunkEmitter.emit('chunk', chalk.dim(`↪ ${result.command}\n`));
1099
+ chunkEmitter.emit('chunk', formatInfo(result.command));
818
1100
  }
819
1101
  else if (result.kind === 'empty') {
820
1102
  // Empty workspace — print the listing so the user sees the
@@ -828,7 +1110,7 @@ export async function runRepl(opts) {
828
1110
  // result.kind === 'cancelled' — print nothing.
829
1111
  }
830
1112
  catch (err) {
831
- chunkEmitter.emit('chunk', chalk.red(`\n[/files] ${err.message}\n`));
1113
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not open the file list', detail: [err.message] }));
832
1114
  }
833
1115
  return;
834
1116
  }
@@ -837,32 +1119,12 @@ export async function runRepl(opts) {
837
1119
  // lands in Body, not above the React frame.
838
1120
  if (trimmed === '/ui' || trimmed.startsWith('/ui ')) {
839
1121
  if (!cfg.ui)
840
- cfg.ui = { rendering: 'auto' };
1122
+ cfg.ui = { rendering: 'auto', look: DEFAULT_CONFIG.ui.look };
841
1123
  const arg = trimmed.slice(3).trim().toLowerCase();
842
1124
  const emit = (s) => chunkEmitter.emit('chunk', s + '\n');
843
- if (arg === '') {
844
- emit('');
845
- emit(`Current UI mode: ${chalk.cyan.bold(cfg.ui.rendering)} ` +
846
- `(effective now: ${chalk.yellow(shouldUseInk() ? 'ink' : 'classic')})`);
847
- emit(chalk.dim(' /ui classic — chalk REPL (default)'));
848
- emit(chalk.dim(' /ui ink — Ink frame (opt-in)'));
849
- emit(chalk.dim(' /ui auto — choose automatically'));
850
- emit(chalk.dim('Change takes effect after /exit + relaunch.'));
851
- }
852
- else if (arg === 'auto' || arg === 'ink' || arg === 'classic') {
853
- cfg.ui.rendering = arg;
854
- try {
855
- await saveConfig(cfg);
856
- emit(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
857
- emit(chalk.dim('Relaunch cspeach to apply.'));
858
- }
859
- catch (err) {
860
- emit(chalk.red(`\n✗ Failed to save config: ${err.message}`));
861
- }
862
- }
863
- else {
864
- emit(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.`));
865
- }
1125
+ if (await handleUiLook(arg, cfg, saveConfig, emit))
1126
+ return;
1127
+ await handleUiRendering(arg, cfg, saveConfig, emit, shouldUseInk() ? 'ink' : 'classic');
866
1128
  return;
867
1129
  }
868
1130
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
@@ -874,23 +1136,42 @@ export async function runRepl(opts) {
874
1136
  return;
875
1137
  }
876
1138
  const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
877
- chunkEmitter.emit('chunk', chalk.dim(`\n↪ Rerouting "${preview}" → /${rr.skill}\n`));
1139
+ chunkEmitter.emit('chunk', '\n' + formatInfo(`Rerouting "${preview}" to /${rr.skill}`));
878
1140
  if (rr.warning)
879
1141
  chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
1142
+ // Re-review 1 — its own AbortController, wired like the main path, so
1143
+ // Ctrl+C and `/exit` stop a rerouted turn the same way.
1144
+ const rerouteAbort = new AbortController();
1145
+ currentTurnAbort = rerouteAbort;
1146
+ interruptCount = 0;
1147
+ if (pendingCancel.applyTo(rerouteAbort))
1148
+ interruptCount = 1;
880
1149
  turnStatusEmitter.start(`/${rr.skill}`);
881
- try {
882
- await runTurn({
1150
+ // A turn error is rendered into the conversation like the normal turn
1151
+ // path (never an unhandled rejection out of `void handleSubmit`).
1152
+ await runRerouteTurn({
1153
+ run: () => runTurn({
883
1154
  provider,
884
1155
  userMessage: rr.prompt,
885
1156
  skill: rr.skill,
886
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1157
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
887
1158
  chunkEmitter,
888
1159
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
889
- });
890
- }
891
- finally {
892
- turnStatusEmitter.stop();
893
- }
1160
+ autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
1161
+ signal: rerouteAbort.signal,
1162
+ }),
1163
+ session,
1164
+ print: (line) => chunkEmitter.emit('chunk', line + '\n'),
1165
+ signal: rerouteAbort.signal,
1166
+ onFinally: () => {
1167
+ turnStatusEmitter.stop();
1168
+ currentTurnAbort = null;
1169
+ postTurn = true;
1170
+ // P2 fix round 1 — /reroute skips pushPostTurnStatus, so run its
1171
+ // turn-end plan step here (close line / 'close' when finished).
1172
+ emitPlanTurnEnd(session);
1173
+ },
1174
+ });
894
1175
  return;
895
1176
  }
896
1177
  // /new — clear any "awaiting skill answer" state so the next prompt
@@ -898,7 +1179,11 @@ export async function runRepl(opts) {
898
1179
  // previous skill's Q&A chain.
899
1180
  if (trimmed === '/new' || trimmed === '/reset') {
900
1181
  session.awaitingSkillAnswer = false;
901
- chunkEmitter.emit('chunk', chalk.dim('\n↩ fresh start — next prompt will be classified normally\n'));
1182
+ // P2 — /new clears the plan checklist (spec §4 lifecycle): the session
1183
+ // list AND the live view, so Ctrl+T / --resume can't bring it back.
1184
+ session.todos = [];
1185
+ todoEmitter.emit('update', []);
1186
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Fresh start · the next prompt picks a skill again'));
902
1187
  return;
903
1188
  }
904
1189
  // /transport — session current-transport state for Rule 9 (transport isolation)
@@ -990,63 +1275,119 @@ export async function runRepl(opts) {
990
1275
  chalk.dim('Type a new question, /new to reset, or /exit to quit.\n'));
991
1276
  return;
992
1277
  }
993
- if ((session.awaitingSkillAnswer || isAckContinuation(trimmed, session.skill)) && session.skill) {
994
- chunkEmitter.emit('chunk', chalk.dim(`↪ Continuing /${session.skill}\n`));
1278
+ // Round 6 item 4c / review I2 — after a resume, a short follow-up
1279
+ // ("continue", a number) stays in the session's skill; anything else
1280
+ // is classified (applyResumedSkill below).
1281
+ if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
1282
+ chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: session.skill }));
995
1283
  skill = session.skill;
996
1284
  }
997
1285
  else if (isBareAck(trimmed)) {
998
1286
  // Bare "y" / "n" / "cancel" with no skill to continue — typing this
999
1287
  // after an aborted turn is almost always a stray keystroke, NOT a
1000
1288
  // request to start work. Don't burn a classifier round-trip on it.
1001
- chunkEmitter.emit('chunk', chalk.dim('↩ no active skill — type /help or a full prompt\n'));
1289
+ chunkEmitter.emit('chunk', formatInfo('No active skill · type /help or a full prompt'));
1002
1290
  return;
1003
1291
  }
1004
1292
  else {
1005
1293
  try {
1006
- const c = await classifyPrompt(prompt, {
1007
- sap_system: selectedAlias,
1008
- last_skill: session.last_skill ?? null,
1009
- last_object: extractedObject ?? session.last_object ?? null,
1010
- });
1011
- if (c.offline) {
1012
- // Remote classify failed twice — local-rules fallback fired.
1013
- // Tell the user so they know the picker preselection came
1014
- // from regex, not the LLM, and surface the real cause.
1015
- chunkEmitter.emit('chunk', chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]\n`));
1294
+ // Fix round 3 (item 8) — the status row reads "routing…" for
1295
+ // the whole classifier wait (was a blank screen for up to 16 s).
1296
+ turnStatusEmitter.routingStart();
1297
+ // Round 4 item 5 — Ctrl+C during routing aborts the classifier
1298
+ // request at once (the spinner used to run until it answered,
1299
+ // up to ~17 s). A cancel queued before routing started aborts
1300
+ // it before any request is made.
1301
+ const routingCtl = new AbortController();
1302
+ routingAbort = routingCtl;
1303
+ if (pendingCancel.pending)
1304
+ routingCtl.abort();
1305
+ let c;
1306
+ try {
1307
+ c = await classifyPrompt(prompt, {
1308
+ sap_system: selectedAlias,
1309
+ last_skill: session.last_skill ?? null,
1310
+ last_object: extractedObject ?? session.last_object ?? null,
1311
+ }, { signal: routingCtl.signal });
1016
1312
  }
1017
- if (c.screenshot) {
1018
- chunkEmitter.emit('chunk', chalk.dim(`┄ Screenshot: ${c.screenshot}
1019
- `));
1313
+ catch (err) {
1314
+ if (!routingCtl.signal.aborted)
1315
+ throw err;
1020
1316
  }
1021
- const decision = decideRouting(c);
1022
- // 2026-05-17 — pass the just-used skill so the picker can
1023
- // default cursor to it (one-Enter "continue" UX for follow-up
1024
- // questions). session.skill = immediately-previous turn's
1025
- // skill, NOT session.last_skill which is from TWO turns ago.
1026
- const continueSkill = session.skill ?? null;
1027
- let pickedResult;
1028
- if (cfg.classifier.safe_mode) {
1029
- pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false, continueSkill));
1317
+ finally {
1318
+ routingAbort = null;
1319
+ turnStatusEmitter.routingEnd();
1030
1320
  }
1031
- else if (decision.kind === 'silent') {
1032
- chunkEmitter.emit('chunk', chalk.dim(`┄ Routed to /${decision.skill}\n`));
1033
- pickedResult = decision.skill;
1321
+ // Review I2/I3 — cancelled while routing: never start the turn.
1322
+ // One line only (re-review 3): the press already printed it when
1323
+ // it came during routing.
1324
+ if (pendingCancel.pending || c === undefined) {
1325
+ pendingCancel.clear();
1326
+ if (cancelAnnouncedPhase !== 'routing') {
1327
+ chunkEmitter.emit('chunk', formatInfo('Turn cancelled · nothing was sent'));
1328
+ }
1329
+ return;
1330
+ }
1331
+ if (c.screenshot) {
1332
+ chunkEmitter.emit('chunk', formatInfo(`Screenshot: ${c.screenshot}`));
1034
1333
  }
1035
- else if (decision.kind === 'coach') {
1036
- pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, true, continueSkill));
1334
+ // Fix 10 (2026-09-26): routing failed mid-skill → stay in the
1335
+ // active skill (one dim line), never the picker. Shared decision
1336
+ // with the classic path (routingFailureOutcome).
1337
+ const failed = routingFailureOutcome(routingFailureFromResult(c), session.skill);
1338
+ if (failed.kind === 'stay') {
1339
+ chunkEmitter.emit('chunk', formatInfo(failed.line));
1340
+ skill = failed.skill;
1037
1341
  }
1038
1342
  else {
1039
- pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, false, continueSkill));
1343
+ if (c.offline) {
1344
+ // Remote classify failed twice — local-rules fallback fired.
1345
+ // Tell the user so they know the picker preselection came
1346
+ // from regex, not the LLM, and surface the real cause.
1347
+ // Fix 9: true wording from the failure kind (a timeout is not "offline").
1348
+ chunkEmitter.emit('chunk', formatAttention(routingFailureNotice(routingFailureFromResult(c), c.transport_error)));
1349
+ }
1350
+ // Round 6 review I2 — after a resume the classifier's confident pick
1351
+ // wins; the resumed skill replaces only the "Pick a skill" card.
1352
+ const decision = resumedSkill ? applyResumedSkill(decideRouting(c), resumedSkill) : decideRouting(c);
1353
+ // 2026-05-17 — pass the just-used skill so the picker can
1354
+ // default cursor to it (one-Enter "continue" UX for follow-up
1355
+ // questions). session.skill = immediately-previous turn's
1356
+ // skill, NOT session.last_skill which is from TWO turns ago.
1357
+ const continueSkill = session.skill ?? null;
1358
+ let pickedResult;
1359
+ if (cfg.classifier.safe_mode) {
1360
+ pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false, continueSkill));
1361
+ }
1362
+ else if (decision.kind === 'silent') {
1363
+ chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'using', skill: decision.skill }));
1364
+ pickedResult = decision.skill;
1365
+ }
1366
+ else if (decision.kind === 'coach') {
1367
+ pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, true, continueSkill));
1368
+ }
1369
+ else {
1370
+ pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, false, continueSkill));
1371
+ }
1372
+ if (pickedResult === PICK_CANCELLED) {
1373
+ chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'cancelled' }));
1374
+ return;
1375
+ }
1376
+ skill = pickedResult;
1377
+ } // end fix-10 fallback branch
1378
+ }
1379
+ catch (err) {
1380
+ const failure = routingFailureFromError(err);
1381
+ const failed = routingFailureOutcome(failure, session.skill);
1382
+ if (failed.kind === 'stay') {
1383
+ // Fix 10: HTTP routing failure mid-skill → stay in the active skill.
1384
+ chunkEmitter.emit('chunk', formatInfo(failed.line));
1385
+ skill = failed.skill;
1040
1386
  }
1041
- if (pickedResult === PICK_CANCELLED) {
1042
- chunkEmitter.emit('chunk', chalk.dim('↩ cancelled — type a new prompt\n'));
1387
+ else {
1388
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: failure ? routingFailureText(failure) : 'Could not pick a skill for this prompt', detail: [err.message], next: 'start the prompt with a skill, e.g. /abap-explain' }));
1043
1389
  return;
1044
1390
  }
1045
- skill = pickedResult;
1046
- }
1047
- catch (err) {
1048
- chunkEmitter.emit('chunk', chalk.red(`\n[classifier error] ${err.message}\n`));
1049
- return;
1050
1391
  }
1051
1392
  }
1052
1393
  }
@@ -1076,7 +1417,7 @@ export async function runRepl(opts) {
1076
1417
  });
1077
1418
  }
1078
1419
  catch (err) {
1079
- chunkEmitter.emit('chunk', chalk.red(`\n[--status] ${err.message}\n`));
1420
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not show the status', detail: [err.message] }));
1080
1421
  }
1081
1422
  session.last_skill = previousSkill ?? null;
1082
1423
  session.last_object = extractedObject ?? previousObject ?? null;
@@ -1108,7 +1449,7 @@ export async function runRepl(opts) {
1108
1449
  if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
1109
1450
  try {
1110
1451
  const { preparePlanResume } = await import('./commands/plan-resume.js');
1111
- const { inkAwarePrompt } = await import('./agent/loop.js');
1452
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
1112
1453
  planResume = await preparePlanResume({
1113
1454
  body: prompt,
1114
1455
  cwd: process.cwd(),
@@ -1120,11 +1461,12 @@ export async function runRepl(opts) {
1120
1461
  // resume guard; inkAwarePrompt answers '' in headless = decline).
1121
1462
  sessionId: session.id,
1122
1463
  prompt: inkAwarePrompt,
1464
+ confirm: inkAwareConfirm,
1123
1465
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1124
1466
  });
1125
1467
  }
1126
1468
  catch (err) {
1127
- chunkEmitter.emit('chunk', chalk.red(`\n[--resume] ${err.message}\n`));
1469
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
1128
1470
  }
1129
1471
  if (!planResume) {
1130
1472
  session.last_skill = previousSkill ?? null;
@@ -1139,7 +1481,7 @@ export async function runRepl(opts) {
1139
1481
  if (planResume.reauditPhaseId) {
1140
1482
  try {
1141
1483
  const { runReauditPass } = await import('./commands/plan-chain.js');
1142
- const { inkAwarePrompt } = await import('./agent/loop.js');
1484
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
1143
1485
  await runReauditPass({
1144
1486
  savedPath: planResume.path,
1145
1487
  phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
@@ -1152,12 +1494,13 @@ export async function runRepl(opts) {
1152
1494
  auditMode: planResume.auditMode,
1153
1495
  auditFlag: planResume.auditFlag,
1154
1496
  prompt: inkAwarePrompt,
1497
+ confirm: inkAwareConfirm,
1155
1498
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1156
1499
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1157
1500
  });
1158
1501
  }
1159
1502
  catch (err) {
1160
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] re-audit failed: ${err.message}\n`));
1503
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan re-audit failed', detail: [err.message] }));
1161
1504
  }
1162
1505
  session.last_skill = previousSkill ?? null;
1163
1506
  session.last_object = extractedObject ?? previousObject ?? null;
@@ -1175,8 +1518,7 @@ export async function runRepl(opts) {
1175
1518
  }
1176
1519
  if (reauditDispatched) {
1177
1520
  pendingDispatchAccessor.set(null);
1178
- chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${reauditDispatched}\n`));
1179
- setImmediate(() => { void handleSubmit(reauditDispatched); });
1521
+ autoDispatch(reauditDispatched);
1180
1522
  }
1181
1523
  return;
1182
1524
  }
@@ -1188,7 +1530,7 @@ export async function runRepl(opts) {
1188
1530
  {
1189
1531
  const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
1190
1532
  if (resetContextForPlanResume(session)) {
1191
- chunkEmitter.emit('chunk', chalk.dim('↪ Plan phase starts in a fresh bounded context — prior transcript cleared\n'));
1533
+ chunkEmitter.emit('chunk', formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared'));
1192
1534
  }
1193
1535
  }
1194
1536
  planResumeMsgStart = session.messages.length;
@@ -1244,7 +1586,7 @@ export async function runRepl(opts) {
1244
1586
  planContinue = { prepared: r.prepared, marker };
1245
1587
  planPhaseStartedAt = marker.attemptStartedAt;
1246
1588
  planResumeMsgStart = session.messages.length;
1247
- chunkEmitter.emit('chunk', chalk.dim(`↪ re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})\n`));
1589
+ chunkEmitter.emit('chunk', formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`));
1248
1590
  }
1249
1591
  else if (r.stale) {
1250
1592
  session.plan_in_flight = null; // plan/phase gone for good — marker is noise
@@ -1271,12 +1613,18 @@ export async function runRepl(opts) {
1271
1613
  // modes. (Pre-fix this whole block didn't exist in the Ink path —
1272
1614
  // sidebar stayed frozen at "0 tok / no turns yet" forever.)
1273
1615
  const turnSnapshot = snapshotTurnStart(session);
1616
+ // Round 6 item 4d — the user cancelled this turn (no receipt when it used no tokens).
1617
+ let turnCancelledByUser = false;
1274
1618
  // Phase D5 (2026-05-17) — install per-turn abort. onInterrupt
1275
1619
  // (Ctrl+C handler) calls currentTurnAbort.abort() while this is
1276
1620
  // non-null, which tears the in-flight provider stream down via
1277
1621
  // the signal we thread into runTurn.
1278
1622
  currentTurnAbort = new AbortController();
1279
1623
  interruptCount = 0;
1624
+ // Review I2/I3 — `/exit` or Ctrl+C arrived during prep: stop the turn
1625
+ // at its very start (the press already said "Stopping…").
1626
+ if (pendingCancel.applyTo(currentTurnAbort))
1627
+ interruptCount = 1;
1280
1628
  // 2026-06-07 — persistent status row lifecycle. Started here (not in
1281
1629
  // loop.ts) so the try/finally GUARANTEES the row clears even when the
1282
1630
  // turn throws; loop.ts only narrates activity changes in between.
@@ -1308,7 +1656,7 @@ export async function runRepl(opts) {
1308
1656
  if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
1309
1657
  setGuardedRunActive(true);
1310
1658
  guardedRunFlagSet = true;
1311
- chunkEmitter.emit('chunk', chalk.dim('▶ guarded mode — auto_approve disabled for this run\n'));
1659
+ chunkEmitter.emit('chunk', formatInfo('Guarded mode · auto-approve is off for this run'));
1312
1660
  }
1313
1661
  // Off-switch (2026-07-06, owner) — announce plainly that this run's
1314
1662
  // phases are NOT audited (honesty: off is a choice, never a lie).
@@ -1320,13 +1668,18 @@ export async function runRepl(opts) {
1320
1668
  provider,
1321
1669
  userMessage: prompt,
1322
1670
  skill: skill,
1323
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1671
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1324
1672
  chunkEmitter,
1325
1673
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
1326
1674
  signal: currentTurnAbort.signal,
1327
1675
  suppressSaveHook: planResume !== null || planContinue !== null,
1676
+ // Task 19 — auto-compact at turn end, except plan resume/continue
1677
+ // turns (their post-turn persist indexes messages from turn start).
1678
+ autoCompact: planResume === null && planContinue === null,
1328
1679
  // A4 — undefined on every non-plan turn (and when tiering is off).
1329
1680
  modelOverride: planModelOverride,
1681
+ // Task 9 — a tier-down, never the session role (proxy must not enforce it).
1682
+ modelRole: planModelOverride ? 'audit' : undefined,
1330
1683
  // Persist the plan revision the instant its manifest is finalised
1331
1684
  // (before the continuation modal) so a Ctrl+C can't lose the phase.
1332
1685
  onPlanManifest: planResumeForHook
@@ -1346,7 +1699,7 @@ export async function runRepl(opts) {
1346
1699
  planSavedEarly = true;
1347
1700
  }
1348
1701
  catch (err) {
1349
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
1702
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
1350
1703
  }
1351
1704
  }
1352
1705
  : planContinueForHook
@@ -1373,7 +1726,7 @@ export async function runRepl(opts) {
1373
1726
  // owns the foreign save / quiet-none handling.
1374
1727
  }
1375
1728
  catch (err) {
1376
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] early continuation save failed: ${err.message}\n`));
1729
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
1377
1730
  }
1378
1731
  }
1379
1732
  : undefined,
@@ -1386,10 +1739,11 @@ export async function runRepl(opts) {
1386
1739
  // to do. Same block classic mode prints; in-session it leads with
1387
1740
  // "type continue" (the partial turn is already saved).
1388
1741
  const { renderTurnError } = await import('./agent/turn-error-ux.js');
1742
+ turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
1389
1743
  await renderTurnError({
1390
1744
  err,
1391
1745
  session,
1392
- userInterrupted: currentTurnAbort?.signal.aborted ?? false,
1746
+ userInterrupted: turnCancelledByUser,
1393
1747
  inSession: true,
1394
1748
  print: (line) => chunkEmitter.emit('chunk', line + '\n'),
1395
1749
  });
@@ -1397,6 +1751,11 @@ export async function runRepl(opts) {
1397
1751
  finally {
1398
1752
  turnStatusEmitter.stop();
1399
1753
  currentTurnAbort = null;
1754
+ // Re-review 2 — from here on a Ctrl+C can only stop an automatic
1755
+ // next plan phase (and only when this is a plan turn or a dispatch
1756
+ // is already queued); otherwise it behaves as idle.
1757
+ postTurn = true;
1758
+ postTurnMayChain = planResume !== null || planContinue !== null;
1400
1759
  // Task 6/7/10 — neither the guarded auto_approve override, the
1401
1760
  // plan-phase enforcement flag, nor the phase-writes declaration may
1402
1761
  // outlive the phase turn. (The deviation flag DOES survive this
@@ -1439,7 +1798,7 @@ export async function runRepl(opts) {
1439
1798
  });
1440
1799
  }
1441
1800
  catch (err) {
1442
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
1801
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
1443
1802
  }
1444
1803
  }
1445
1804
  // A1 (defect D23) + Task 6 — harness-owned continuation, now the full
@@ -1452,7 +1811,7 @@ export async function runRepl(opts) {
1452
1811
  if (planResume && planOutcome) {
1453
1812
  try {
1454
1813
  const { runPostPhaseChain } = await import('./commands/plan-chain.js');
1455
- const { inkAwarePrompt } = await import('./agent/loop.js');
1814
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
1456
1815
  await runPostPhaseChain({
1457
1816
  savedPath: planOutcome.savedPath,
1458
1817
  executedPhaseId: planResume.nextPhaseId,
@@ -1470,12 +1829,13 @@ export async function runRepl(opts) {
1470
1829
  // finally; thread the captured value for the report line.
1471
1830
  subagentDispatches: planSubagentDispatches,
1472
1831
  prompt: inkAwarePrompt,
1832
+ confirm: inkAwareConfirm,
1473
1833
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1474
1834
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1475
1835
  });
1476
1836
  }
1477
1837
  catch (err) {
1478
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] post-phase chain failed: ${err.message}\n`));
1838
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
1479
1839
  }
1480
1840
  }
1481
1841
  // C1 — continuation post-turn: fallback-persist (hook may not have
@@ -1514,13 +1874,13 @@ export async function runRepl(opts) {
1514
1874
  }
1515
1875
  }
1516
1876
  catch (err) {
1517
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] continuation save failed: ${err.message}\n`));
1877
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
1518
1878
  }
1519
1879
  }
1520
1880
  if (planContinue && planOutcome) {
1521
1881
  try {
1522
1882
  const { runContinuationChain } = await import('./commands/plan-continue.js');
1523
- const { inkAwarePrompt } = await import('./agent/loop.js');
1883
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
1524
1884
  await runContinuationChain({
1525
1885
  prepared: planContinue.prepared,
1526
1886
  marker: planContinue.marker,
@@ -1529,12 +1889,13 @@ export async function runRepl(opts) {
1529
1889
  cwd: process.cwd(),
1530
1890
  subagentDispatches: planSubagentDispatches,
1531
1891
  prompt: inkAwarePrompt,
1892
+ confirm: inkAwareConfirm,
1532
1893
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1533
1894
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1534
1895
  });
1535
1896
  }
1536
1897
  catch (err) {
1537
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] continuation chain failed: ${err.message}\n`));
1898
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
1538
1899
  }
1539
1900
  }
1540
1901
  // C1 — the phase reached a resolved terminal (revision persisted, chain
@@ -1565,6 +1926,7 @@ export async function runRepl(opts) {
1565
1926
  snapshot: turnSnapshot,
1566
1927
  chunkEmitter,
1567
1928
  turnModel: planModelOverride ?? session.model,
1929
+ cancelled: turnCancelledByUser,
1568
1930
  });
1569
1931
  session.last_skill = previousSkill ?? null;
1570
1932
  session.last_object = extractedObject ?? previousObject ?? null;
@@ -1582,11 +1944,11 @@ export async function runRepl(opts) {
1582
1944
  // the transcript reset — it runs for typed AND dispatched resumes,
1583
1945
  // and only AFTER preparePlanResume succeeds, so a failed prepare
1584
1946
  // never destroys the session context.
1585
- chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
1586
1947
  // Schedule on next tick so the current handleSubmit's promise
1587
1948
  // resolves cleanly before the next one starts. Recursion via
1588
1949
  // setImmediate keeps the call stack flat across long chains.
1589
- setImmediate(() => { void handleSubmit(dispatched); });
1950
+ // (autoDispatch skips it when a post-turn Ctrl+C / /exit asked to stop.)
1951
+ autoDispatch(dispatched);
1590
1952
  }
1591
1953
  };
1592
1954
  // BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
@@ -1605,47 +1967,149 @@ export async function runRepl(opts) {
1605
1967
  // check here is a backstop for any direct caller that bypasses
1606
1968
  // beginSubmit's guard — it queues the line as steering, exactly like
1607
1969
  // beginSubmit does mid-turn, instead of starting a twin.
1970
+ /** Round 4 item 9b — the in-flight (or last) dispatch; settled before unmount on exit. */
1971
+ let dispatchTail = null;
1608
1972
  const handleSubmit = async (line) => {
1609
1973
  if (turnStatusEmitter.isBusy()) {
1610
1974
  const t = line.trim();
1975
+ // Fix round 3 (item 5): slash commands are never steering text.
1976
+ const kind = classifyMidTurnLine(t);
1977
+ if (kind.kind === 'exit') {
1978
+ onExitRequest();
1979
+ return;
1980
+ }
1981
+ if (kind.kind === 'slash') {
1982
+ chunkEmitter.emit('chunk', midTurnSlashHint(kind.command));
1983
+ return;
1984
+ }
1611
1985
  if (t.length > 0) {
1612
1986
  enqueueSteering(t);
1613
- const echo = t.split('\n')[0].slice(0, 60);
1614
- chunkEmitter.emit('chunk', chalk.cyan(`↳ queued ▸ ${echo}${t.length > echo.length ? '…' : ''}\n`));
1987
+ chunkEmitter.emit('chunk', steeringEchoLine(t));
1615
1988
  }
1616
1989
  return;
1617
1990
  }
1991
+ idleKeepalive.disarm(); // Task 15 — a submit ends the idle period
1618
1992
  turnStatusEmitter.reserve();
1993
+ postTurn = false;
1994
+ postTurnMayChain = false;
1619
1995
  try {
1620
- await dispatchSubmit(line);
1996
+ // Round 4 item 9b — the exit path waits (capped) for this to finish.
1997
+ const run = dispatchSubmit(line);
1998
+ dispatchTail = run;
1999
+ await run;
1621
2000
  }
1622
2001
  finally {
1623
2002
  turnStatusEmitter.release();
2003
+ postTurn = false;
2004
+ postTurnMayChain = false;
2005
+ cancelAnnouncedPhase = null;
2006
+ // A cancel asked for during prep of a dispatch that never started a
2007
+ // turn must not leak into the next one.
2008
+ pendingCancel.clear();
2009
+ interruptCount = 0;
2010
+ // `/exit` typed mid-turn: the turn has now stopped and saved — exit.
2011
+ if (exitAfterTurn) {
2012
+ done = true;
2013
+ if (resolveDone)
2014
+ resolveDone();
2015
+ }
2016
+ // Task 15 — back at the prompt: warm the cache while the user reads
2017
+ // (no-op unless this submit made a new cacheable request). Not when
2018
+ // the submit ended in an exit (merge: UI round-3 /exit mid-turn).
2019
+ if (!done)
2020
+ idleKeepalive.armAfterTurn(session);
2021
+ }
2022
+ };
2023
+ // Fix round 3 (item 5) — `/exit` / `/quit` typed while a turn runs:
2024
+ // cancel the turn the same way Ctrl+C does, then exit once dispatch
2025
+ // returns (handleSubmit's finally). Mid-prep (no abort controller yet)
2026
+ // the cancel is held and applied when the turn creates its controller,
2027
+ // or stops routing outright (review I2).
2028
+ let exitAfterTurn = false;
2029
+ // Final review B-I1 — a /rewind restore is not a turn and runs outside
2030
+ // handleSubmit: interrupts during it are decided as idle, /exit waits for
2031
+ // it, and the cancel/exit state is reset when it ends.
2032
+ const restoreGuard = createRestoreGuard({
2033
+ resetDispatchState: () => {
2034
+ cancelAnnouncedPhase = null;
2035
+ pendingCancel.clear();
2036
+ interruptCount = 0;
2037
+ },
2038
+ exit: () => { done = true; if (resolveDone)
2039
+ resolveDone(); },
2040
+ });
2041
+ const onExitRequest = () => {
2042
+ if (restoreGuard.active) {
2043
+ restoreGuard.requestExit();
2044
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting when the restore has finished'));
2045
+ return;
2046
+ }
2047
+ if (!turnStatusEmitter.isBusy() && !currentTurnAbort) {
2048
+ done = true;
2049
+ if (resolveDone)
2050
+ resolveDone();
2051
+ return;
2052
+ }
2053
+ exitAfterTurn = true;
2054
+ if (currentTurnAbort && interruptCount === 0) {
2055
+ interruptCount++;
2056
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · exiting when it has saved'));
2057
+ currentTurnAbort.abort();
2058
+ }
2059
+ else if (!currentTurnAbort) {
2060
+ announcePendingCancel(true);
1624
2061
  }
1625
2062
  };
1626
2063
  // Phase D5 (2026-05-17) — Ctrl+C handler routed through Ink.
1627
2064
  // First press during turn: abort the turn (preserves partial work).
1628
2065
  // Second press during the SAME turn: hard-exit (escape hatch in case
1629
2066
  // the abort doesn't take effect for some reason).
1630
- // Press at idle: clean exit (printGoodbye + leaveAltScreen).
2067
+ // Press at idle (fix round 3, item 4): the first press only arms the
2068
+ // exit — App shows "Press Ctrl+C again to exit" — and a second press
2069
+ // within IDLE_EXIT_WINDOW_MS exits cleanly (printGoodbye + leaveAltScreen).
2070
+ // Cards / sheets / palettes eat their own Ctrl+C and never get here.
2071
+ let idleInterruptAt = 0;
1631
2072
  const onInterrupt = () => {
1632
- if (currentTurnAbort) {
2073
+ const now = Date.now();
2074
+ const action = decideInterrupt({
2075
+ turnActive: currentTurnAbort !== null,
2076
+ // Review I3 — routing / prep in flight without a controller yet is
2077
+ // not idle: cancel the pending turn, never arm "press again".
2078
+ busy: turnStatusEmitter.isBusy() && !restoreGuard.active,
2079
+ phase: cancelPhase(),
2080
+ canChain: canChainNow(),
2081
+ turnPresses: interruptCount,
2082
+ lastIdleAt: idleInterruptAt,
2083
+ now,
2084
+ });
2085
+ if (action === 'abort-turn') {
1633
2086
  interruptCount++;
1634
- if (interruptCount === 1) {
1635
- chunkEmitter.emit('chunk', chalk.yellow('\n⚠ Interrupting turn — saving partial work...\n'));
1636
- currentTurnAbort.abort();
1637
- }
1638
- else {
1639
- // Second Ctrl+C during the same turn — give up and exit.
1640
- chunkEmitter.emit('chunk', chalk.red('\n✗ Force-exit on second Ctrl+C.\n'));
1641
- unmount();
1642
- process.stdout.write('\x1b[?25h'); // restore cursor
1643
- printGoodbye(session);
1644
- process.exit(130); // 128 + SIGINT
1645
- }
2087
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · saving the work done so far'));
2088
+ currentTurnAbort.abort();
2089
+ }
2090
+ else if (action === 'cancel-pending') {
2091
+ interruptCount++;
2092
+ announcePendingCancel(false);
2093
+ }
2094
+ else if (action === 'force-exit') {
2095
+ // Second Ctrl+C during the same turn — give up and exit.
2096
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting · second Ctrl+C'));
2097
+ unmount();
2098
+ process.stdout.write('\x1b[?25h'); // restore cursor
2099
+ printGoodbye(session);
2100
+ // Final review B M-2 — through gracefulExit (fetch drain, capped).
2101
+ // Re-review item 3 — mark the session done first, so nothing queued
2102
+ // (auto-dispatch, the loop) starts during the drain window.
2103
+ done = true;
2104
+ exitAfterTurn = true;
2105
+ void gracefulExit(130); // 128 + SIGINT
2106
+ }
2107
+ else if (action === 'arm') {
2108
+ idleInterruptAt = now;
2109
+ return 'armed';
1646
2110
  }
1647
2111
  else {
1648
- // Idle — clean exit, same path as /exit.
2112
+ // Second idle press inside the window — clean exit, same path as /exit.
1649
2113
  done = true;
1650
2114
  if (resolveDone)
1651
2115
  resolveDone();
@@ -1665,7 +2129,10 @@ export async function runRepl(opts) {
1665
2129
  enableBracketedPaste();
1666
2130
  process.on('exit', () => disableBracketedPaste());
1667
2131
  const inkStdin = new InkStdinPasteGuard();
1668
- process.stdin.pipe(inkStdin);
2132
+ // Live fix 2026-09-26 — an @inquirer prompt that ran before this point
2133
+ // (resume picker, SAP picker, API key, first run, onboarding) leaves
2134
+ // Windows console stdin dead to the pipe. Cycle raw mode + flow first.
2135
+ pipeRevivedStdin(inkStdin, process.stdin);
1669
2136
  // Task 3 (ux-wave3) — rewind wiring, shared with the classic path through
1670
2137
  // executeRestore (which holds the single-flight reserve for the FULL
1671
2138
  // restore — review I1a: a restore counts as busy, submits become steering —
@@ -1676,25 +2143,25 @@ export async function runRepl(opts) {
1676
2143
  const inkRequestApproval = (change, risk, transport) => new Promise((resolve) => {
1677
2144
  approvalEmitter.emit('request', { change, effectiveRisk: risk, transport, resolve });
1678
2145
  });
1679
- const runInkRestore = (c) => executeRestore(c, {
2146
+ const runInkRestore = (c) => restoreGuard.run(() => executeRestore(c, {
1680
2147
  adt, cfg, alias: selectedAlias,
1681
- toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2148
+ toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1682
2149
  suppliedTransport: currentTransportAccessor.get() ?? undefined,
1683
2150
  requestApproval: inkRequestApproval,
1684
2151
  // deps.log streams each pipeline step dim to scrollback live; the final
1685
- // ✓ / ↩ cancelled / ✗ summary comes from executeRestore's print.
2152
+ // the done / cancelled / failed summary comes from executeRestore's print.
1686
2153
  log: (line) => chunkEmitter.emit('chunk', chalk.dim(line) + '\n'),
1687
- }, (line) => chunkEmitter.emit('chunk', line + '\n'));
2154
+ }, (line) => chunkEmitter.emit('chunk', line + '\n')));
1688
2155
  const { unmount } = render(React.createElement(App, {
1689
2156
  chunkEmitter,
1690
2157
  onSubmit: (line) => { void handleSubmit(line); },
1691
2158
  onSlashTrigger: () => { },
1692
2159
  onHistoryTrigger: () => { },
1693
2160
  getToolCalls: () => session.toolCalls,
1694
- // Task 5 (ux-wave2) — todo-panel seed accessor (getToolCalls
1695
- // pattern): App reads persisted todos on mount and on every
1696
- // Ctrl+T open, so a resumed session shows its list before any
1697
- // todo_set fires in this process.
2161
+ // Live-checklist seed accessor (getToolCalls pattern; piece 2 P2):
2162
+ // App reads persisted todos on mount and when the rewind panel
2163
+ // closes, so a resumed session shows its plan before any todo_set
2164
+ // fires in this process.
1698
2165
  getTodos: () => session.todos ?? [],
1699
2166
  // Task 3 (ux-wave3) — rewind panel accessors. Candidates join
1700
2167
  // session.toolCalls with the on-disk snapshot list (async fs read),
@@ -1711,6 +2178,7 @@ export async function runRepl(opts) {
1711
2178
  sapRole: cfg.sap[selectedAlias]?.role,
1712
2179
  sapAlias: selectedAlias,
1713
2180
  onInterrupt,
2181
+ onExitRequest,
1714
2182
  }), {
1715
2183
  // Bug 2 (wave1 live-fix) — Ink reads from the paste guard, not
1716
2184
  // process.stdin directly. The guard satisfies Ink's full stdin
@@ -1732,7 +2200,13 @@ export async function runRepl(opts) {
1732
2200
  patchConsole: true,
1733
2201
  });
1734
2202
  await donePromise;
2203
+ // Round 4 item 9b — a second idle Ctrl+C right after a turn resolved the
2204
+ // exit while post-turn work (saving, audit) still ran. Let it finish,
2205
+ // capped so a stuck step can never hold the exit.
2206
+ const exitTail = await settleWithin(dispatchTail, EXIT_TAIL_CAP_MS);
1735
2207
  unmount();
2208
+ setTraceEmitter(undefined);
2209
+ idleKeepalive.disarm(); // Task 15 — no pings after /exit
1736
2210
  // Bug 2 (wave1 live-fix) — undo the paste plumbing so the user's
1737
2211
  // shell doesn't keep receiving marker-wrapped pastes after exit.
1738
2212
  process.stdin.unpipe(inkStdin);
@@ -1744,6 +2218,11 @@ export async function runRepl(opts) {
1744
2218
  // line lands in the user's shell scrollback alongside the session
1745
2219
  // history. In inline mode the conversation IS in scrollback (no
1746
2220
  // alt-screen erased it), so this is just a friendly bookend.
2221
+ // Review M7 — the cap expired: the process exits next, so say the save may
2222
+ // not have landed instead of cutting it off silently.
2223
+ if (exitTail === 'timeout') {
2224
+ console.log(formatAttention({ text: 'Post-turn work did not finish', fact: 'the session file may be incomplete' }).trimEnd());
2225
+ }
1747
2226
  printGoodbye(session);
1748
2227
  return 0;
1749
2228
  }
@@ -1756,7 +2235,10 @@ export async function runRepl(opts) {
1756
2235
  // a single line before readline sees it. See src/repl/bracketed-paste.ts.
1757
2236
  enableBracketedPaste();
1758
2237
  const pasteInput = new BracketedPasteDecoder();
1759
- process.stdin.pipe(pasteInput);
2238
+ // Review I2: the classic path is reached through the same pre-REPL inquirer
2239
+ // prompts (resume picker, SAP picker, password, first run) — revive stdin
2240
+ // with the full reset cycle before piping, exactly like the Ink branch.
2241
+ pipeRevivedStdin(pasteInput, process.stdin);
1760
2242
  const history = loadHistory();
1761
2243
  const rl = readline.createInterface({
1762
2244
  input: pasteInput,
@@ -1790,7 +2272,7 @@ export async function runRepl(opts) {
1790
2272
  // empty. Attached ONLY for non-TTY stdin: interactive type-ahead during
1791
2273
  // a turn keeps its existing semantics.
1792
2274
  const pipedStdin = !process.stdin.isTTY;
1793
- const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl) : noopEarlyLineBuffer();
2275
+ const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl, process.stdin) : noopEarlyLineBuffer();
1794
2276
  // B2 fix (2026-06-12) — classic-mode notice channel. Tool handlers emit
1795
2277
  // 'warn'/'info' (and pre-formatted 'chunk' lines like the write verify
1796
2278
  // chain) on ctx.chunkEmitter; in classic mode that slot used to be
@@ -1800,8 +2282,9 @@ export async function runRepl(opts) {
1800
2282
  // keep their direct-stdout classic path (spinner stays enabled).
1801
2283
  const classicOutputEmitter = createClassicOutputEmitter();
1802
2284
  // Prompt string: 'CSPeach ' + '›' in peach colour
1803
- const promptStr = 'CSPeach ' + chalk.hex(PEACH)('›') + ' ';
1804
- // When we dispatch an inquirer sub-prompt (coaching picker, pickSkill),
2285
+ // I1 — the classic prompt is not legacy-pinned; it takes the brand role (spec §1.1).
2286
+ const promptStr = 'CSPeach ' + paint('brand', '›') + ' ';
2287
+ // When we dispatch an inquirer sub-prompt (e.g. the coaching picker),
1805
2288
  // readline is paused to give inquirer exclusive stdin access. On resolve
1806
2289
  // inquirer sometimes disturbs the stdin stream in a way that makes our
1807
2290
  // readline fire 'close' even though the user did NOT press Ctrl-D. Suppress
@@ -1835,7 +2318,7 @@ export async function runRepl(opts) {
1835
2318
  // v0.6 — module-scope flag the per-turn SIGINT handler sets to true while
1836
2319
  // a turn is in flight. Both `rl.on('close')` and `rl.on('SIGINT')` below
1837
2320
  // check this flag and bail out so they don't race against the turn-scoped
1838
- // handler that wants to abort the LLM stream and render "✋ Turn cancelled."
2321
+ // handler that wants to abort the LLM stream and render "· Turn cancelled"
1839
2322
  // Without this flag, Ctrl+C during a stream lands in the readline SIGINT
1840
2323
  // path first (line buffer is empty during streaming → "Goodbye" + exit),
1841
2324
  // killing the CLI before the turn handler can do anything useful.
@@ -1875,7 +2358,7 @@ export async function runRepl(opts) {
1875
2358
  // v0.6 — turn-scoped SIGINT handler (registered just before runTurn
1876
2359
  // and torn down right after) takes precedence. If a turn is in flight,
1877
2360
  // the user wants to interrupt THE TURN, not exit the CLI. Returning
1878
- // early lets the turn handler run its abort + "✋ Turn cancelled." UX.
2361
+ // early lets the turn handler run its abort + "· Turn cancelled" UX.
1879
2362
  if (turnInProgress)
1880
2363
  return;
1881
2364
  // Inspect the readline's internal line buffer + our prefill state.
@@ -2001,9 +2484,11 @@ export async function runRepl(opts) {
2001
2484
  // can pause us and suppress the spurious 'close' inquirer emits on
2002
2485
  // release. See src/repl/inquirer-guard.ts for the failure mode this
2003
2486
  // prevents (CSPeach silently exiting after a tool-mediated prompt).
2487
+ // Fix 8 (2026-09-26): pass pasteInput so a prompt detaches it from stdin —
2488
+ // keys typed into the prompt must not be replayed into the REPL line.
2004
2489
  registerReadline(rl, (v) => {
2005
2490
  suppressRlClose = v;
2006
- });
2491
+ }, { input: pasteInput });
2007
2492
  // 2026-05-08: one-time workspace-default migration. The default workspace
2008
2493
  // changed from `~/CSPeach/` to project-rooted `<git-root>/.cspeach/`.
2009
2494
  // Existing users with files in ~/CSPeach/ get asked once how to handle
@@ -2058,7 +2543,7 @@ export async function runRepl(opts) {
2058
2543
  // transcript reset — it runs for typed AND dispatched resumes, and
2059
2544
  // only AFTER preparePlanResume succeeds, so a failed prepare never
2060
2545
  // destroys the session context.
2061
- console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
2546
+ console.log(formatInfo(`Auto-routed: ${dispatchedCmd}`).trimEnd());
2062
2547
  line = dispatchedCmd;
2063
2548
  }
2064
2549
  else if (earlyLines.size() > 0) {
@@ -2076,8 +2561,12 @@ export async function runRepl(opts) {
2076
2561
  }
2077
2562
  else {
2078
2563
  const effectivePrompt = pendingPrefill ? promptStr + pendingPrefill : promptStr;
2564
+ // Task 15 — idle at the prompt: warm the cache (no-op unless the last
2565
+ // turn made a new cacheable request); disarmed once the line arrives.
2566
+ idleKeepalive.armAfterTurn(session);
2079
2567
  try {
2080
2568
  line = await rl.question(effectivePrompt);
2569
+ idleKeepalive.disarm();
2081
2570
  }
2082
2571
  catch {
2083
2572
  // rl.question rejects for two distinct reasons:
@@ -2091,6 +2580,7 @@ export async function runRepl(opts) {
2091
2580
  // discriminates the two cases reliably.
2092
2581
  const rlAny = rl;
2093
2582
  if (rlAny.closed) {
2583
+ idleKeepalive.disarm();
2094
2584
  // UX-5 #5 — piped stdin: a multi-line chunk can resolve the
2095
2585
  // pending question with line 1 and QUEUE line 2+ before EOF
2096
2586
  // closes the readline. Drain the queue before exiting.
@@ -2114,6 +2604,9 @@ export async function runRepl(opts) {
2114
2604
  const trimmed = line.trim();
2115
2605
  if (!trimmed)
2116
2606
  continue;
2607
+ // Round 6 review I2 — see the Ink path.
2608
+ const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
2609
+ resumeKeepsSkill = false;
2117
2610
  // Persist non-empty input to history file (Task 8: consecutive
2118
2611
  // duplicates are skipped inside the shared writer).
2119
2612
  appendHistoryEntry(trimmed);
@@ -2147,7 +2640,7 @@ export async function runRepl(opts) {
2147
2640
  continue;
2148
2641
  }
2149
2642
  if (trimmed.endsWith(' /cancel')) {
2150
- console.log(chalk.dim('↩ cancelled'));
2643
+ console.log(formatRoutingLine({ kind: 'cancelled' }).trimEnd());
2151
2644
  continue;
2152
2645
  }
2153
2646
  if (trimmed === '/exit' || trimmed === '/quit') {
@@ -2215,7 +2708,7 @@ export async function runRepl(opts) {
2215
2708
  if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
2216
2709
  const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
2217
2710
  const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
2218
- console.log(chalk.dim(modeLine));
2711
+ console.log(formatInfo(modeLine).trimEnd());
2219
2712
  sapStateStore.update({ writeMode: mode });
2220
2713
  continue;
2221
2714
  }
@@ -2228,13 +2721,9 @@ export async function runRepl(opts) {
2228
2721
  const recapWs = await resolveWorkspacePath().catch(() => undefined);
2229
2722
  const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
2230
2723
  console.log('');
2231
- if (recapLines.length === 0) {
2232
- console.log(chalk.dim('nothing to recap yet'));
2233
- }
2234
- else {
2235
- for (const recapLine of recapLines)
2236
- console.log(chalk.dim(recapLine));
2237
- }
2724
+ console.log(recapLines.length === 0
2725
+ ? formatInfo('Nothing to recap yet').trimEnd()
2726
+ : formatPanel('Recap', recapLines.map((l) => ' ' + paint('dim', sanitizeControlChars(l)))).join('\n'));
2238
2727
  continue;
2239
2728
  }
2240
2729
  // Phase 2 conversation hygiene (2026-05-16) — classic-mode /compact.
@@ -2249,15 +2738,15 @@ export async function runRepl(opts) {
2249
2738
  return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
2250
2739
  // model-governance step 2d — compact model resolves env > local >
2251
2740
  // server > built-in (== COMPACTION_MODEL when nothing set).
2252
- { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
2741
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
2253
2742
  };
2254
2743
  try {
2255
- console.log(chalk.dim('\nCompacting older turns into a summary...'));
2744
+ console.log('\n' + formatInfo('Compacting older turns into a summary…').trimEnd());
2256
2745
  const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
2257
2746
  console.log(formatCompactResult(result, compactCfg.keep_recent_turns));
2258
2747
  }
2259
2748
  catch (err) {
2260
- console.log(chalk.red(`\n/compact failed: ${err instanceof Error ? err.message : String(err)}\n`));
2749
+ console.log('\n' + formatFailure({ what: 'Compact failed', detail: [err instanceof Error ? err.message : String(err)] }));
2261
2750
  }
2262
2751
  continue;
2263
2752
  }
@@ -2278,7 +2767,7 @@ export async function runRepl(opts) {
2278
2767
  const { openFilePicker } = await import('./repl/file-picker.js');
2279
2768
  const result = await openFilePicker();
2280
2769
  if (result.kind === 'command') {
2281
- console.log(chalk.dim(`↪ Picked. Press Enter to run, or edit first.`));
2770
+ console.log(formatInfo('Picked · press Enter to run, or edit first').trimEnd());
2282
2771
  pendingPrefill = result.command;
2283
2772
  }
2284
2773
  else if (result.kind === 'empty') {
@@ -2293,7 +2782,7 @@ export async function runRepl(opts) {
2293
2782
  // result.kind === 'cancelled' — print nothing.
2294
2783
  }
2295
2784
  catch (err) {
2296
- console.log(chalk.red(`\n[/files] ${err.message}\n`));
2785
+ console.log('\n' + formatFailure({ what: 'Could not open the file list', detail: [err.message] }));
2297
2786
  }
2298
2787
  continue;
2299
2788
  }
@@ -2302,31 +2791,11 @@ export async function runRepl(opts) {
2302
2791
  // Defensive: a config that predates the [ui] section may have cfg.ui === undefined
2303
2792
  // if loaded before loader.ts's deep-merge fix. Initialise to the default.
2304
2793
  if (!cfg.ui)
2305
- cfg.ui = { rendering: 'auto' };
2794
+ cfg.ui = { rendering: 'auto', look: DEFAULT_CONFIG.ui.look };
2306
2795
  const arg = trimmed.slice(3).trim().toLowerCase();
2307
- if (arg === '') {
2308
- console.log(`\nCurrent UI mode: ${chalk.cyan.bold(cfg.ui.rendering)} ` +
2309
- `(effective now: ${chalk.yellow(shouldUseInk() ? 'ink' : 'classic')})\n`);
2310
- console.log(chalk.dim(' /ui classic — chalk REPL (v0.2 style — default, fully tested)'));
2311
- console.log(chalk.dim(' /ui ink — Ink frame (v0.3 — opt-in while polish is pending)'));
2312
- console.log(chalk.dim(' /ui auto — choose automatically based on terminal'));
2313
- console.log(chalk.dim('\nChanges are written to ~/.cspeach/config.toml and'));
2314
- console.log(chalk.dim('take effect the next time you launch cspeach.\n'));
2315
- }
2316
- else if (arg === 'auto' || arg === 'ink' || arg === 'classic') {
2317
- cfg.ui.rendering = arg;
2318
- try {
2319
- await saveConfig(cfg);
2320
- console.log(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
2321
- console.log(chalk.dim('Relaunch cspeach (`/exit` then `cspeach`) to apply.\n'));
2322
- }
2323
- catch (err) {
2324
- console.log(chalk.red(`\n✗ Failed to save config: ${err.message}\n`));
2325
- }
2326
- }
2327
- else {
2328
- console.log(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.\n`));
2329
- }
2796
+ if (await handleUiLook(arg, cfg, saveConfig, (s) => console.log(s)))
2797
+ continue;
2798
+ await handleUiRendering(arg, cfg, saveConfig, (s) => console.log(s), shouldUseInk() ? 'ink' : 'classic');
2330
2799
  continue;
2331
2800
  }
2332
2801
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
@@ -2338,15 +2807,26 @@ export async function runRepl(opts) {
2338
2807
  continue;
2339
2808
  }
2340
2809
  const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
2341
- console.log(chalk.dim(`↪ Rerouting "${preview}" → /${rr.skill}`));
2810
+ console.log(formatInfo(`Rerouting "${preview}" to /${rr.skill}`).trimEnd());
2342
2811
  if (rr.warning)
2343
2812
  console.log(chalk.dim(` (${rr.warning})`));
2344
- await runTurn({
2345
- provider,
2346
- userMessage: rr.prompt,
2347
- skill: rr.skill,
2348
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2349
- pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2813
+ // A turn error prints the same stderr block as the normal turn path.
2814
+ await runRerouteTurn({
2815
+ run: () => runTurn({
2816
+ provider,
2817
+ userMessage: rr.prompt,
2818
+ skill: rr.skill,
2819
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2820
+ pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2821
+ autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
2822
+ }),
2823
+ session,
2824
+ print: (line) => console.error(line),
2825
+ onFinally: () => {
2826
+ // P2 fix round 1 — /reroute skips pushPostTurnStatus, so run its
2827
+ // turn-end plan step here (classic prints the block once).
2828
+ emitPlanTurnEnd(session);
2829
+ },
2350
2830
  });
2351
2831
  continue;
2352
2832
  }
@@ -2354,7 +2834,8 @@ export async function runRepl(opts) {
2354
2834
  // classified afresh instead of continuing the previous skill's Q&A chain.
2355
2835
  if (trimmed === '/new' || trimmed === '/reset') {
2356
2836
  session.awaitingSkillAnswer = false;
2357
- console.log(chalk.dim('↩ fresh start — next prompt will be classified normally'));
2837
+ session.todos = []; // P2 — /new clears the plan (spec §4)
2838
+ console.log(formatInfo('Fresh start · the next prompt picks a skill again').trimEnd());
2358
2839
  continue;
2359
2840
  }
2360
2841
  // /transport — session current-transport state for Rule 9 (transport isolation)
@@ -2409,7 +2890,7 @@ export async function runRepl(opts) {
2409
2890
  // the prefilled buffer so iter 3 starts fresh, clear the
2410
2891
  // guard, and skip dispatch.
2411
2892
  lastCancelledSlashLine = null;
2412
- console.log(chalk.dim('↩ dropped'));
2893
+ console.log(formatInfo('Dropped').trimEnd());
2413
2894
  process.nextTick(() => prefillReadline(rl, ''));
2414
2895
  continue;
2415
2896
  }
@@ -2421,7 +2902,7 @@ export async function runRepl(opts) {
2421
2902
  const prefix = head.slice(1); // drop the leading /
2422
2903
  const picked = await openSlashPicker(prefix);
2423
2904
  if (picked === null) {
2424
- console.log(chalk.dim('↩ cancelled — edit, or press Enter again to drop'));
2905
+ console.log(formatInfo('Cancelled · edit, or press Enter again to drop').trimEnd());
2425
2906
  lastCancelledSlashLine = trimmed;
2426
2907
  process.nextTick(() => prefillReadline(rl, trimmed));
2427
2908
  continue;
@@ -2448,13 +2929,13 @@ export async function runRepl(opts) {
2448
2929
  // Without this the line went to the LLM with a bare/partial
2449
2930
  // @ — wasted a turn and confused the user who'd just cancelled.
2450
2931
  lastCancelledAtLine = null;
2451
- console.log(chalk.dim('↩ dropped'));
2932
+ console.log(formatInfo('Dropped').trimEnd());
2452
2933
  process.nextTick(() => prefillReadline(rl, ''));
2453
2934
  continue;
2454
2935
  }
2455
2936
  const pickedFile = await openAtPicker(atPrefix);
2456
2937
  if (pickedFile === null) {
2457
- console.log(chalk.dim('↩ cancelled — edit, or press Enter again to drop'));
2938
+ console.log(formatInfo('Cancelled · edit, or press Enter again to drop').trimEnd());
2458
2939
  lastCancelledAtLine = resolvedTrimmed;
2459
2940
  process.nextTick(() => prefillReadline(rl, resolvedTrimmed));
2460
2941
  continue;
@@ -2499,9 +2980,7 @@ export async function runRepl(opts) {
2499
2980
  // then asked an unanswerable question. Every skill now carries an
2500
2981
  // `asks` line with an example (skill-catalog.ts).
2501
2982
  const asks = SKILL_CATALOG.find((e) => `/${e.name}` === head)?.asks;
2502
- console.log(chalk.dim(asks
2503
- ? `↪ ${head} — ${asks}`
2504
- : `${'↪'} ${head} — type your prompt and press Enter`));
2983
+ console.log(formatInfo(asks ? `${head} — ${asks}` : `${head} — type your prompt and press Enter`).trimEnd());
2505
2984
  console.log(chalk.dim(' Enter to send · /cancel to abandon'));
2506
2985
  lastEmptyPrefill = head;
2507
2986
  }
@@ -2567,15 +3046,16 @@ export async function runRepl(opts) {
2567
3046
  chalk.dim('Type a new question, /new to reset, or /exit to quit.'));
2568
3047
  continue;
2569
3048
  }
2570
- if ((session.awaitingSkillAnswer || isAckContinuation(trimmed, session.skill)) && session.skill) {
2571
- console.log(chalk.dim(`↪ Continuing /${session.skill}`));
3049
+ // Round 6 item 4c / review I2 — see the Ink path.
3050
+ if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
3051
+ console.log(formatRoutingLine({ kind: 'continuing', skill: session.skill }).trimEnd());
2572
3052
  skill = session.skill;
2573
3053
  }
2574
3054
  else if (isBareAck(trimmed)) {
2575
3055
  // Bare "y" / "n" / "cancel" with no skill to continue — almost always
2576
3056
  // a stray keystroke after an aborted turn, not a request to start
2577
3057
  // work. Don't burn a classifier round-trip on it.
2578
- console.log(chalk.dim('↩ no active skill — type /help or a full prompt'));
3058
+ console.log(formatInfo('No active skill · type /help or a full prompt').trimEnd());
2579
3059
  continue;
2580
3060
  }
2581
3061
  else {
@@ -2585,46 +3065,76 @@ export async function runRepl(opts) {
2585
3065
  last_skill: session.last_skill ?? null,
2586
3066
  last_object: extractedObject ?? session.last_object ?? null,
2587
3067
  });
2588
- if (c.offline) {
2589
- console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
2590
- }
2591
3068
  if (c.screenshot)
2592
- console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
2593
- const decision = decideRouting(c);
2594
- // 2026-05-17 — pass the just-used skill so the picker can
2595
- // default cursor to it. session.skill = immediately-previous
2596
- // turn's skill, NOT session.last_skill (TWO turns ago).
2597
- const continueSkillClassic = session.skill ?? null;
2598
- let pickedResult;
2599
- // Coaching picker path — inquirer takes over stdin. Use the shared
2600
- // withInquirer guard so the suppress-close window stays active
2601
- // across the close-fire tick (inline guard cleared the flag
2602
- // synchronously, leaking the close-fire and dropping CSPeach to
2603
- // the shell mid-session).
2604
- const runPicker = async (params) => {
2605
- return withInquirer(() => coachingPickerClassic(params));
2606
- };
2607
- if (cfg.classifier.safe_mode) {
2608
- // Safe mode overrides the decision — always open the full picker.
2609
- pickedResult = await runPicker(buildCoachingParams(c, trimmed, false, continueSkillClassic));
2610
- }
2611
- else if (decision.kind === 'silent') {
2612
- console.log(chalk.dim(`┄ Routed to /${decision.skill}`));
2613
- pickedResult = decision.skill;
3069
+ console.log(formatInfo(`Screenshot: ${c.screenshot}`).trimEnd());
3070
+ // Fix 10 (2026-09-26): routing failed mid-skill → stay in the
3071
+ // active skill (one dim line), never the picker. Same decision as
3072
+ // the Ink path (routingFailureOutcome).
3073
+ const failed = routingFailureOutcome(routingFailureFromResult(c), session.skill);
3074
+ if (failed.kind === 'stay') {
3075
+ console.log(formatInfo(failed.line).trimEnd());
3076
+ skill = failed.skill;
2614
3077
  }
2615
3078
  else {
2616
- const preSelect = decision.kind === 'coach';
2617
- pickedResult = await runPicker(buildCoachingParams(decision.result, trimmed, preSelect, continueSkillClassic));
3079
+ if (c.offline) {
3080
+ // Fix 9: true wording from the failure kind (a timeout is not "offline").
3081
+ console.log(formatAttention(routingFailureNotice(routingFailureFromResult(c), c.transport_error)).trimEnd());
3082
+ }
3083
+ // Round 6 review I2 — see the Ink path.
3084
+ const decision = resumedSkill ? applyResumedSkill(decideRouting(c), resumedSkill) : decideRouting(c);
3085
+ // 2026-05-17 — pass the just-used skill so the picker can
3086
+ // default cursor to it. session.skill = immediately-previous
3087
+ // turn's skill, NOT session.last_skill (TWO turns ago).
3088
+ const continueSkillClassic = session.skill ?? null;
3089
+ let pickedResult;
3090
+ // Coaching picker path — inquirer takes over stdin. Use the shared
3091
+ // withInquirer guard so the suppress-close window stays active
3092
+ // across the close-fire tick (inline guard cleared the flag
3093
+ // synchronously, leaking the close-fire and dropping CSPeach to
3094
+ // the shell mid-session).
3095
+ const runPicker = async (params) => {
3096
+ return withInquirer(() => coachingPickerClassic(params));
3097
+ };
3098
+ if (pipedStdin) {
3099
+ // Round 7 item 8a — piped stdin has no keyboard to answer the
3100
+ // picker (it ended in "readline was closed"): run the router's
3101
+ // top pick, like one-shot.
3102
+ const p = pipedRoutingPick(c);
3103
+ for (const l of p.lines)
3104
+ console.log(l);
3105
+ pickedResult = p.skill;
3106
+ }
3107
+ else if (cfg.classifier.safe_mode) {
3108
+ // Safe mode overrides the decision — always open the full picker.
3109
+ pickedResult = await runPicker(buildCoachingParams(c, trimmed, false, continueSkillClassic));
3110
+ }
3111
+ else if (decision.kind === 'silent') {
3112
+ console.log(formatRoutingLine({ kind: 'using', skill: decision.skill }).trimEnd());
3113
+ pickedResult = decision.skill;
3114
+ }
3115
+ else {
3116
+ const preSelect = decision.kind === 'coach';
3117
+ pickedResult = await runPicker(buildCoachingParams(decision.result, trimmed, preSelect, continueSkillClassic));
3118
+ }
3119
+ if (pickedResult === PICK_CANCELLED) {
3120
+ console.log(formatRoutingLine({ kind: 'cancelled' }).trimEnd());
3121
+ continue;
3122
+ }
3123
+ skill = pickedResult;
3124
+ } // end fix-10 fallback branch
3125
+ }
3126
+ catch (err) {
3127
+ const failure = routingFailureFromError(err);
3128
+ const failed = routingFailureOutcome(failure, session.skill);
3129
+ if (failed.kind === 'stay') {
3130
+ // Fix 10: HTTP routing failure mid-skill → stay in the active skill.
3131
+ console.log(formatInfo(failed.line).trimEnd());
3132
+ skill = failed.skill;
2618
3133
  }
2619
- if (pickedResult === PICK_CANCELLED) {
2620
- console.log(chalk.dim('↩ cancelled — type a new prompt'));
3134
+ else {
3135
+ console.log('\n' + formatFailure({ what: failure ? routingFailureText(failure) : 'Could not pick a skill for this prompt', detail: [err.message], next: 'start the prompt with a skill, e.g. /abap-explain' }).trimEnd());
2621
3136
  continue;
2622
3137
  }
2623
- skill = pickedResult;
2624
- }
2625
- catch (err) {
2626
- console.log(chalk.red(`\n[classifier error] ${err.message}`));
2627
- continue;
2628
3138
  }
2629
3139
  }
2630
3140
  }
@@ -2652,7 +3162,7 @@ export async function runRepl(opts) {
2652
3162
  });
2653
3163
  }
2654
3164
  catch (err) {
2655
- console.log(chalk.red(`\n[--status] ${err.message}\n`));
3165
+ console.log('\n' + formatFailure({ what: 'Could not show the status', detail: [err.message] }));
2656
3166
  }
2657
3167
  session.last_skill = previousSkill ?? null;
2658
3168
  session.last_object = extractedObject ?? previousObject ?? null;
@@ -2677,7 +3187,7 @@ export async function runRepl(opts) {
2677
3187
  if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
2678
3188
  try {
2679
3189
  const { preparePlanResume } = await import('./commands/plan-resume.js');
2680
- const { inkAwarePrompt } = await import('./agent/loop.js');
3190
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
2681
3191
  planResume = await preparePlanResume({
2682
3192
  body: prompt,
2683
3193
  cwd: process.cwd(),
@@ -2688,11 +3198,12 @@ export async function runRepl(opts) {
2688
3198
  // Task 9 — run-lease identity + takeover prompt (classic mirror).
2689
3199
  sessionId: session.id,
2690
3200
  prompt: inkAwarePrompt,
3201
+ confirm: inkAwareConfirm,
2691
3202
  log: (...lines) => lines.forEach((l) => console.log(l)),
2692
3203
  });
2693
3204
  }
2694
3205
  catch (err) {
2695
- console.log(chalk.red(`\n[--resume] ${err.message}\n`));
3206
+ console.log('\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
2696
3207
  }
2697
3208
  if (!planResume) {
2698
3209
  session.last_skill = previousSkill ?? null;
@@ -2706,7 +3217,7 @@ export async function runRepl(opts) {
2706
3217
  if (planResume.reauditPhaseId) {
2707
3218
  try {
2708
3219
  const { runReauditPass } = await import('./commands/plan-chain.js');
2709
- const { inkAwarePrompt } = await import('./agent/loop.js');
3220
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
2710
3221
  await runReauditPass({
2711
3222
  savedPath: planResume.path,
2712
3223
  phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
@@ -2718,12 +3229,13 @@ export async function runRepl(opts) {
2718
3229
  auditMode: planResume.auditMode,
2719
3230
  auditFlag: planResume.auditFlag,
2720
3231
  prompt: inkAwarePrompt,
3232
+ confirm: inkAwareConfirm,
2721
3233
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
2722
3234
  log: (...lines) => lines.forEach((l) => console.log(l)),
2723
3235
  });
2724
3236
  }
2725
3237
  catch (err) {
2726
- console.log(chalk.red(`\n[plan] re-audit failed: ${err.message}\n`));
3238
+ console.log('\n' + formatFailure({ what: 'Plan re-audit failed', detail: [err.message] }));
2727
3239
  }
2728
3240
  // Task 9 (review fix) — release ONLY when the re-audit leg queued no
2729
3241
  // follow-up (classic mirror; the loop-top pre-prompt consumer picks
@@ -2742,7 +3254,7 @@ export async function runRepl(opts) {
2742
3254
  {
2743
3255
  const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
2744
3256
  if (resetContextForPlanResume(session)) {
2745
- console.log(chalk.dim('↪ Plan phase starts in a fresh bounded context — prior transcript cleared'));
3257
+ console.log(formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared').trimEnd());
2746
3258
  }
2747
3259
  }
2748
3260
  planResumeMsgStart = session.messages.length;
@@ -2788,7 +3300,7 @@ export async function runRepl(opts) {
2788
3300
  planContinue = { prepared: r.prepared, marker };
2789
3301
  planPhaseStartedAt = marker.attemptStartedAt;
2790
3302
  planResumeMsgStart = session.messages.length;
2791
- console.log(chalk.dim(`↪ re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`));
3303
+ console.log(formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`).trimEnd());
2792
3304
  }
2793
3305
  else if (r.stale) {
2794
3306
  session.plan_in_flight = null; // plan/phase gone for good — marker is noise
@@ -2805,6 +3317,8 @@ export async function runRepl(opts) {
2805
3317
  let planSubagentDispatches;
2806
3318
  const planResumeForHook = planResume;
2807
3319
  const turnStart = Date.now();
3320
+ // UI fix round (1.1.20) — card waits are excluded from the receipt's time.
3321
+ const turnStartPausedMs = turnStatusEmitter.pausedMs();
2808
3322
  // H1 — `session.usage` is session-LIFETIME (initialised once in
2809
3323
  // newSession, saved across turns). The footer's "this turn" label was
2810
3324
  // misleading — it actually showed cumulative lifetime tokens. Snapshot
@@ -2843,7 +3357,7 @@ export async function runRepl(opts) {
2843
3357
  return;
2844
3358
  turnSigintFired = true;
2845
3359
  interruptedByUser = true;
2846
- process.stderr.write(chalk.yellow('\n⚠ Interrupting turn — saving partial work...\n'));
3360
+ process.stderr.write('\n' + formatInfo('Stopping the turn · saving the work done so far'));
2847
3361
  turnAbort.abort();
2848
3362
  // Remove ourselves so a SECOND Ctrl+C falls through to the
2849
3363
  // existing rl SIGINT handler (which clears the line / exits) once
@@ -2871,7 +3385,7 @@ export async function runRepl(opts) {
2871
3385
  if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
2872
3386
  setGuardedRunActive(true);
2873
3387
  guardedRunFlagSet = true;
2874
- console.log(chalk.dim('▶ guarded mode — auto_approve disabled for this run'));
3388
+ console.log(formatInfo('Guarded mode · auto-approve is off for this run').trimEnd());
2875
3389
  }
2876
3390
  // Off-switch (2026-07-06, owner) — announce plainly that this run's
2877
3391
  // phases are NOT audited (honesty: off is a choice, never a lie).
@@ -2887,8 +3401,13 @@ export async function runRepl(opts) {
2887
3401
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2888
3402
  signal: turnAbort.signal,
2889
3403
  suppressSaveHook: planResume !== null || planContinue !== null,
3404
+ // Task 19 — auto-compact at turn end, except plan resume/continue
3405
+ // turns (their post-turn persist indexes messages from turn start).
3406
+ autoCompact: planResume === null && planContinue === null,
2890
3407
  // A4 — undefined on every non-plan turn (and when tiering is off).
2891
3408
  modelOverride: planModelOverride,
3409
+ // Task 9 — a tier-down, never the session role (proxy must not enforce it).
3410
+ modelRole: planModelOverride ? 'audit' : undefined,
2892
3411
  onPlanManifest: planResumeForHook
2893
3412
  ? async (assistantText) => {
2894
3413
  try {
@@ -2906,7 +3425,7 @@ export async function runRepl(opts) {
2906
3425
  planSavedEarly = true;
2907
3426
  }
2908
3427
  catch (err) {
2909
- console.log(chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
3428
+ console.log('\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
2910
3429
  }
2911
3430
  }
2912
3431
  : planContinueForHook
@@ -2933,7 +3452,7 @@ export async function runRepl(opts) {
2933
3452
  // owns the foreign save / quiet-none handling.
2934
3453
  }
2935
3454
  catch (err) {
2936
- console.log(chalk.red(`\n[plan] early continuation save failed: ${err.message}\n`));
3455
+ console.log('\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
2937
3456
  }
2938
3457
  }
2939
3458
  : undefined,
@@ -2990,7 +3509,7 @@ export async function runRepl(opts) {
2990
3509
  });
2991
3510
  }
2992
3511
  catch (err) {
2993
- console.log(chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
3512
+ console.log('\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
2994
3513
  }
2995
3514
  }
2996
3515
  // A1 (defect D23) + Task 6 — harness-owned continuation, now the full
@@ -3001,7 +3520,7 @@ export async function runRepl(opts) {
3001
3520
  if (planResume && planOutcome) {
3002
3521
  try {
3003
3522
  const { runPostPhaseChain } = await import('./commands/plan-chain.js');
3004
- const { inkAwarePrompt } = await import('./agent/loop.js');
3523
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
3005
3524
  await runPostPhaseChain({
3006
3525
  savedPath: planOutcome.savedPath,
3007
3526
  executedPhaseId: planResume.nextPhaseId,
@@ -3018,12 +3537,13 @@ export async function runRepl(opts) {
3018
3537
  // Fix-wave (item 1) — counter consumed in the runTurn finally.
3019
3538
  subagentDispatches: planSubagentDispatches,
3020
3539
  prompt: inkAwarePrompt,
3540
+ confirm: inkAwareConfirm,
3021
3541
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
3022
3542
  log: (...lines) => lines.forEach((l) => console.log(l)),
3023
3543
  });
3024
3544
  }
3025
3545
  catch (err) {
3026
- console.log(chalk.red(`\n[plan] post-phase chain failed: ${err.message}\n`));
3546
+ console.log('\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
3027
3547
  }
3028
3548
  }
3029
3549
  // C1 — continuation post-turn: fallback-persist (hook may not have
@@ -3062,13 +3582,13 @@ export async function runRepl(opts) {
3062
3582
  }
3063
3583
  }
3064
3584
  catch (err) {
3065
- console.log(chalk.red(`\n[plan] continuation save failed: ${err.message}\n`));
3585
+ console.log('\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
3066
3586
  }
3067
3587
  }
3068
3588
  if (planContinue && planOutcome) {
3069
3589
  try {
3070
3590
  const { runContinuationChain } = await import('./commands/plan-continue.js');
3071
- const { inkAwarePrompt } = await import('./agent/loop.js');
3591
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
3072
3592
  await runContinuationChain({
3073
3593
  prepared: planContinue.prepared,
3074
3594
  marker: planContinue.marker,
@@ -3077,12 +3597,13 @@ export async function runRepl(opts) {
3077
3597
  cwd: process.cwd(),
3078
3598
  subagentDispatches: planSubagentDispatches,
3079
3599
  prompt: inkAwarePrompt,
3600
+ confirm: inkAwareConfirm,
3080
3601
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
3081
3602
  log: (...lines) => lines.forEach((l) => console.log(l)),
3082
3603
  });
3083
3604
  }
3084
3605
  catch (err) {
3085
- console.log(chalk.red(`\n[plan] continuation chain failed: ${err.message}\n`));
3606
+ console.log('\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
3086
3607
  }
3087
3608
  }
3088
3609
  // C1 — the phase reached a resolved terminal (revision persisted, chain
@@ -3118,10 +3639,13 @@ export async function runRepl(opts) {
3118
3639
  cacheRead: turnStartCostSnapshot.cacheRead,
3119
3640
  cacheCreate: turnStartCostSnapshot.cacheCreate,
3120
3641
  startedAt: turnStart,
3642
+ pausedMs: turnStartPausedMs,
3121
3643
  },
3122
3644
  // A4 — price the turn at the model that actually ran it (classic-path
3123
3645
  // mirror of the Ink call site above).
3124
3646
  turnModel: planModelOverride ?? session.model,
3647
+ // Review M4 — a user cancel with no tokens prints no receipt here too.
3648
+ cancelled: interruptedByUser,
3125
3649
  });
3126
3650
  // Post-turn roll-forward (Step 7c.2 / 7c.5) — the skill from the turn that
3127
3651
  // JUST finished becomes `last_skill` for the next turn's classifier context.