@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
@@ -4,9 +4,12 @@
4
4
  // who just resumed (or typed /recap) without scrolling back through the
5
5
  // transcript.
6
6
  //
7
- // ↩ last session: /abap-generate · 4 turns · 3h ago · "add a validation…"
8
- // ▶ tasks: 2 done / 3 open — next: write the BDEF validation
9
- // ▷ plan Maintenance Requests: phase 3/7 validated — next: /abap-plan --resume @…
7
+ // last session: /abap-generate · 4 turns · 3h ago · "add a validation…"
8
+ // tasks: 2 of 5 done — next: write the BDEF validation
9
+ // plan Maintenance Requests: phase 3 of 7 validated — next: /abap-plan --resume @…
10
+ //
11
+ // Piece 2: buildRecapParts() exposes the three facts without labels so the
12
+ // panels start screen can place them (resume line / "!" plan notice).
10
13
  //
11
14
  // Line 1 always prints (unless the session has zero user turns → recap is
12
15
  // empty and callers print nothing). Line 2 only when the session's todo list
@@ -26,18 +29,14 @@ import { readProjectFile } from '../projects/status.js';
26
29
  import { parsePlanContent } from '../projects/plan-schema.js';
27
30
  import { statusesFromItems, isPhaseSatisfied } from '../projects/plan-run.js';
28
31
  import { defaultWorkspaceFor } from '../projects/workspace.js';
29
- import { userPromptText } from './user-prompt.js';
32
+ import { userPromptText, promptPreviewText } from './user-prompt.js';
30
33
  /** Wave-1 truncation rule (see /reroute): ≤60 chars total, '...' suffix. */
31
34
  function truncatePrompt(s) {
32
35
  return s.length > 60 ? s.slice(0, 57) + '...' : s;
33
36
  }
34
- /**
35
- * Build the recap lines for a session. Returns [] for an empty session
36
- * (zero user turns) — callers print nothing in that case.
37
- */
38
- export function buildRecap(session, cwd, opts) {
39
- const lines = [];
40
- // ── line 1: session orientation ─────────────────────────────────────────
37
+ export function buildRecapParts(session, cwd, opts) {
38
+ const parts = { last: null, tasks: null, plan: null };
39
+ // ── fact 1: session orientation ─────────────────────────────────────────
41
40
  // "User turns" = typed prompts, not tool_result rounds — same counting
42
41
  // rule as the resume picker (session/store.ts); see session/user-prompt.ts.
43
42
  const userTexts = session.messages
@@ -45,7 +44,7 @@ export function buildRecap(session, cwd, opts) {
45
44
  .filter((t) => t !== null);
46
45
  const turns = userTexts.length;
47
46
  if (turns === 0)
48
- return [];
47
+ return parts;
49
48
  const skillLabel = session.skill ? `/${session.skill}` : 'no skill';
50
49
  // Last user prompt: prefer the persisted lastUserPrompt (user-typed prose;
51
50
  // null after slash-only commands), fall back to the last string user
@@ -54,32 +53,45 @@ export function buildRecap(session, cwd, opts) {
54
53
  let prompt = session.lastUserPrompt;
55
54
  if (!prompt) {
56
55
  const last = userTexts[userTexts.length - 1];
57
- prompt = last.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim() || null;
56
+ prompt = promptPreviewText(last) || null;
58
57
  }
59
58
  const quoted = prompt ? `"${truncatePrompt(prompt)}"` : '(no prompt)';
60
59
  const ago = formatTimeAgo(session.last_turn_at, opts?.now);
61
- lines.push(`↩ last session: ${skillLabel} · ${turns} turn${turns === 1 ? '' : 's'} · ${ago} · ${quoted}`);
62
- // ── line 2: todo list (Task 4's todo_set) — only when open work exists ──
60
+ parts.last = `${skillLabel} · ${turns} turn${turns === 1 ? '' : 's'} · ${ago} · ${quoted}`;
61
+ // ── fact 2: todo list (Task 4's todo_set) — only when open work exists ──
63
62
  const todos = session.todos ?? [];
64
63
  const open = todos.filter((t) => t.status !== 'completed');
65
64
  if (open.length > 0) {
66
65
  const done = todos.length - open.length;
67
66
  const next = todos.find((t) => t.status === 'in_progress') ?? todos.find((t) => t.status === 'pending');
68
- lines.push(`▶ tasks: ${done} done / ${open.length} open — next: ${next.text}`);
67
+ parts.tasks = `${done} of ${todos.length} done — next: ${next.text}`;
69
68
  }
70
- // ── line 3: newest plan family with unsatisfied phases ──────────────────
71
- // Never throws: any fs/parse problem silently drops the line.
69
+ // ── fact 3: newest plan family with unsatisfied phases ──────────────────
70
+ // Never throws: any fs/parse problem silently drops the fact.
72
71
  try {
73
- const planLine = buildPlanLine(opts?.workspaceDir ?? defaultWorkspaceFor(cwd));
74
- if (planLine)
75
- lines.push(planLine);
72
+ parts.plan = buildPlanPart(opts?.workspaceDir ?? defaultWorkspaceFor(cwd));
76
73
  }
77
74
  catch { /* recap must never throw */ }
75
+ return parts;
76
+ }
77
+ /**
78
+ * Build the recap lines for a session. Returns [] for an empty session
79
+ * (zero user turns) — callers print nothing in that case.
80
+ */
81
+ export function buildRecap(session, cwd, opts) {
82
+ const p = buildRecapParts(session, cwd, opts);
83
+ if (p.last === null)
84
+ return [];
85
+ const lines = [`last session: ${p.last}`];
86
+ if (p.tasks)
87
+ lines.push(`tasks: ${p.tasks}`);
88
+ if (p.plan)
89
+ lines.push(`plan ${p.plan.text} — next: ${p.plan.next}`);
78
90
  return lines;
79
91
  }
80
92
  /**
81
93
  * Scan `workspaceDir` for the newest plan family and, when it still has
82
- * unsatisfied phases, produce the plan recap line. Returns null when there
94
+ * unsatisfied phases, produce the plan recap fact. Returns null when there
83
95
  * is no plan, the newest plan family is fully satisfied, or its envelope is
84
96
  * invalid (silent skip by design).
85
97
  *
@@ -90,7 +102,7 @@ export function buildRecap(session, cwd, opts) {
90
102
  * file's mtime. Non-plan envelopes (design, spec-gap, …) are skipped so the
91
103
  * newest PLAN family is found even when a newer non-plan artefact exists.
92
104
  */
93
- function buildPlanLine(workspaceDir) {
105
+ function buildPlanPart(workspaceDir) {
94
106
  let names;
95
107
  try {
96
108
  names = readdirSync(workspaceDir);
@@ -157,7 +169,7 @@ function buildPlanLine(workspaceDir) {
157
169
  const validated = pc.content.phases.filter((p) => isPhaseSatisfied(statuses[p.id], p.audit)).length;
158
170
  if (total === 0 || validated >= total)
159
171
  return null; // fully satisfied — no line
160
- return `▷ plan ${env.title}: phase ${validated}/${total} validated — next: /abap-plan --resume @${basename(cand.path)}`;
172
+ return { text: `${env.title}: phase ${validated} of ${total} validated`, next: `/abap-plan --resume @${basename(cand.path)}` };
161
173
  }
162
174
  return null;
163
175
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Task 3 fix round 1 (I1, 2026-09-27) — re-pin a resumed session's model.
3
+ *
4
+ * The agent loop sends the session's PINNED `session.model` on every call.
5
+ * A resumed session carries the id saved at creation (possibly retired, or
6
+ * an id the served config has since moved away from), so on resume the
7
+ * model is re-resolved ONCE through the same path new sessions use —
8
+ * resolveModelRole('session_default', cfg), after the awaited served config —
9
+ * marked 'predicted', and saved. It then stays pinned for the rest of the
10
+ * session (managed-mode adoption in the loop may still update it).
11
+ */
12
+ import { resolveModelRole } from '../models/resolve.js';
13
+ import { saveSession } from './store.js';
14
+ export async function repinResumedSessionModel(session, cfg) {
15
+ session.model = resolveModelRole('session_default', cfg);
16
+ session.served_model_source = 'predicted';
17
+ await saveSession(session);
18
+ }
@@ -1,57 +1,113 @@
1
- import { select } from '@inquirer/prompts';
1
+ import { select } from '../repl/themed-prompts.js';
2
2
  import chalk from 'chalk';
3
- import { listSessions, loadSession } from './store.js';
3
+ import { listSessions, loadSession, RESUME_PICKER_LIMIT } from './store.js';
4
4
  import { formatTimeAgo } from './time-ago.js';
5
5
  import { runRepl } from '../repl.js';
6
- export async function runResume(id) {
6
+ import { formatFailure, formatAttention } from '../renderer/notice-shape.js';
7
+ import { getGlyphs } from '../renderer/glyphs.js';
8
+ import { paint } from '../renderer/theme.js';
9
+ import { truncateToWidth } from '../renderer/ui-width.js';
10
+ import { interruptedPickerLine } from './interrupt-reason.js';
11
+ import stringWidth from 'string-width';
12
+ import { STANDALONE_ALIAS } from '../tools/index.js';
13
+ import { loadConfig, resolveStandalone } from '../config/loader.js';
14
+ /** inquirer's Ctrl+C / force-close rejection. */
15
+ function isPromptCancel(err) {
16
+ return err?.name === 'ExitPromptError';
17
+ }
18
+ /**
19
+ * The sessions the resume picker offers: round 6 item 4b — no 0-turn
20
+ * sessions (a start-then-quit left a "0t (no prompt)" row every time) unless
21
+ * interrupted; interrupted ones first (most likely wanted after a crash),
22
+ * then most recent.
23
+ */
24
+ export function pickerSessions(sessions) {
25
+ return sessions
26
+ .filter((s) => s.userTurnCount > 0 || s.interrupted)
27
+ .sort((a, b) => {
28
+ if (a.interrupted !== b.interrupted)
29
+ return a.interrupted ? -1 : 1;
30
+ return b.lastTurnAt.localeCompare(a.lastTurnAt);
31
+ });
32
+ }
33
+ export async function runResume(id, opts = {}) {
7
34
  let targetId = id;
8
35
  if (!targetId) {
9
- const sessions = await listSessions(20);
36
+ const sessions = pickerSessions(await listSessions(RESUME_PICKER_LIMIT, { skipEmpty: true }));
10
37
  if (sessions.length === 0) {
11
38
  console.log('No recent sessions.');
12
39
  return 0;
13
40
  }
14
- // Promote interrupted sessions to the top of the list — they're the
15
- // ones the user is most likely looking for after a crash.
16
- const sorted = [...sessions].sort((a, b) => {
17
- if (a.interrupted !== b.interrupted)
18
- return a.interrupted ? -1 : 1;
19
- return b.lastTurnAt.localeCompare(a.lastTurnAt);
20
- });
21
- targetId = await select({
22
- message: `Pick a session to resume:
41
+ const sorted = sessions;
42
+ try {
43
+ targetId = await select({
44
+ message: `Pick a session to resume:
23
45
  ${formatSessionHeader()}`,
24
- choices: sorted.map((s) => ({
25
- value: s.id,
26
- name: formatSessionRow(s),
27
- description: s.interrupted
28
- ? `Interrupted (${s.interruptedReason ?? 'unknown'}) — type "continue" after resume`
29
- : `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
30
- })),
31
- });
46
+ choices: sorted.map((s) => ({
47
+ value: s.id,
48
+ name: formatSessionRow(s),
49
+ // Round 6 item 4a — the answered line shows the id, not the whole row.
50
+ short: s.id,
51
+ description: s.interrupted
52
+ ? interruptedPickerLine(s.interruptedReason)
53
+ : `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
54
+ })),
55
+ });
56
+ }
57
+ catch (err) {
58
+ // Ctrl+C in the picker: exit quietly, like Claude Code (fix round 3).
59
+ if (isPromptCancel(err))
60
+ return 0;
61
+ throw err;
62
+ }
32
63
  }
33
64
  let session;
34
65
  try {
35
66
  session = await loadSession(targetId);
36
67
  }
37
68
  catch (err) {
38
- console.error(chalk.red(`\n[error] ${err.message}\n`));
69
+ console.error('\n' + formatFailure({ what: 'Could not load the session', detail: [err.message], next: 'cspeach --resume (pick from the list)' }));
39
70
  return 1;
40
71
  }
41
72
  const unfinished = session.toolCalls.filter((c) => !c.completed);
73
+ // Round 6 review I3 — moving a session with unfinished SAP changes to
74
+ // another system would leave those changes unreconcilable: refuse.
75
+ const from = session.sap_system;
76
+ if (opts.sapAlias && unfinished.length > 0 && from && from !== STANDALONE_ALIAS && from !== opts.sapAlias) {
77
+ console.error(formatFailure({ what: `This session has unfinished changes on ${from} · resume without --sap-alias` }).trimEnd());
78
+ return 1;
79
+ }
80
+ // Final batch 4 + re-review — the same rule for standalone on a SAP
81
+ // session, whether --no-sap asked for it or the persisted no_sap setting.
82
+ if (unfinished.length > 0 && from && from !== STANDALONE_ALIAS) {
83
+ const cfg = await loadConfig();
84
+ if (resolveStandalone(cfg, opts.noSap === true)) {
85
+ const how = opts.noSap ? 'resume without --no-sap' : 'resume with SAP enabled';
86
+ console.error(formatFailure({ what: `This session has unfinished changes on ${from} · ${how}` }).trimEnd());
87
+ return 1;
88
+ }
89
+ }
42
90
  if (unfinished.length > 0) {
43
- console.log(chalk.yellow(`\n⚠ ${unfinished.length} pending mutation(s) from the previous session:\n`));
91
+ console.log('\n' + formatAttention({ text: `${unfinished.length} unfinished SAP change${unfinished.length === 1 ? '' : 's'} from the previous session`, fact: 'choose how to reconcile below' }));
44
92
  for (const u of unfinished) {
45
93
  console.log(` ${u.tool} ${JSON.stringify(u.args)}`);
46
94
  }
47
- const action = await select({
48
- message: 'Reconcile how?',
49
- choices: [
50
- { value: 'reconcile', name: 'Query SAP to find out what actually happened (requires SAP reachable)' },
51
- { value: 'continue', name: 'Leave as unresolved, continue (risky)' },
52
- { value: 'abandon', name: 'Abandon this session (file is kept on disk)' },
53
- ],
54
- });
95
+ let action;
96
+ try {
97
+ action = await select({
98
+ message: 'Reconcile how?',
99
+ choices: [
100
+ { value: 'reconcile', name: 'Query SAP to find out what actually happened (requires SAP reachable)' },
101
+ { value: 'continue', name: 'Leave as unresolved, continue (risky)' },
102
+ { value: 'abandon', name: 'Abandon this session (file is kept on disk)' },
103
+ ],
104
+ });
105
+ }
106
+ catch (err) {
107
+ if (isPromptCancel(err))
108
+ return 0;
109
+ throw err;
110
+ }
55
111
  if (action === 'abandon')
56
112
  return 0;
57
113
  if (action === 'reconcile') {
@@ -65,19 +121,31 @@ ${formatSessionHeader()}`,
65
121
  // kept (a stub from v0.1). Now resume genuinely picks up where the prior
66
122
  // turn left off — including mid-turn-interrupted ones recoverable thanks
67
123
  // to the persistence work in agent/loop.ts.
68
- return runRepl({ resumedSession: session });
124
+ // Round 6 review C1 — the start flags reach the REPL (they used to be
125
+ // dropped, so --sap-alias never switched a resumed session).
126
+ return runRepl({ resumedSession: session, sapAlias: opts.sapAlias, noSap: opts.noSap });
69
127
  }
70
128
  // Column widths shared by the rows and the header so the labels line up.
71
129
  const W = { id: 8, time: 10, sap: 8, skill: 22, turns: 5 };
72
- export function formatSessionRow(s) {
73
- const flag = s.interrupted ? chalk.yellow('⚠ ') : ' ';
130
+ /**
131
+ * One picker row. Round 4 item 10 — the preview is cut to the terminal
132
+ * width (a long one wrapped onto a second line at 100 columns and broke the
133
+ * table): room = columns − the picker's 2-cell cursor gutter − the fixed
134
+ * columns − 1 spare cell.
135
+ */
136
+ export function formatSessionRow(s, columns = process.stdout.columns ?? 80) {
137
+ const flag = s.interrupted ? paint('warn', getGlyphs().attention) + ' ' : ' ';
74
138
  const idCol = chalk.cyan(s.id.padEnd(W.id));
75
139
  const timeCol = chalk.gray(formatTimeAgo(s.lastTurnAt).padEnd(W.time));
76
140
  const sapCol = chalk.gray((s.sapSystem ?? '-').padEnd(W.sap));
77
141
  const skillCol = chalk.gray(`/${s.skill ?? '-'}`.padEnd(W.skill));
78
142
  const turns = chalk.gray(`${s.userTurnCount}t`.padStart(W.turns));
79
- const snippet = s.promptSnippet ?? chalk.dim('(no prompt)');
80
- return `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} ${snippet}`;
143
+ const prefix = `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} `;
144
+ // Measured (string-width ignores colour codes), not summed: a value wider
145
+ // than its column pads nothing.
146
+ const room = Math.max(8, columns - 2 - stringWidth(prefix) - 1);
147
+ const snippet = s.promptSnippet != null ? truncateToWidth(s.promptSnippet, room) : chalk.dim('(no prompt)');
148
+ return `${prefix}${snippet}`;
81
149
  }
82
150
  /**
83
151
  * One dim line naming the row columns (2026-09-23 — rows had no labels). The
@@ -1,9 +1,12 @@
1
+ import { isUserStopReason } from './interrupt-reason.js';
1
2
  import fs from 'node:fs/promises';
3
+ import { existsSync, readdirSync } from 'node:fs';
2
4
  import path from 'node:path';
3
5
  import crypto from 'node:crypto';
4
6
  import { sessionsDir, pinnedSessionsDir } from '../config/paths.js';
5
7
  import { SCHEMA_VERSION } from './schema.js';
6
- import { userPromptText, isUserPrompt } from './user-prompt.js';
8
+ import { userPromptText, isUserPrompt, promptPreviewText } from './user-prompt.js';
9
+ import { stripUnsignedThinking, dropTrailingThinkingOnly } from '../agent/repair-partial.js';
7
10
  export function makeSessionId() {
8
11
  return crypto.randomBytes(4).toString('hex');
9
12
  }
@@ -12,6 +15,19 @@ function sessionPath(id, startedAt) {
12
15
  const stamp = startedAt.replace(/[:.]/g, '-');
13
16
  return path.join(dir, `${stamp}-${id}.json`);
14
17
  }
18
+ /** Round 6 review I1 — has this session ever been written to disk? */
19
+ export function sessionSaved(session) {
20
+ try {
21
+ if (existsSync(sessionPath(session.id, session.started_at)))
22
+ return true;
23
+ // Final batch 2 — a pinned session lives in the pinned folder (any stamp).
24
+ const suffix = `-${session.id}.json`;
25
+ return existsSync(pinnedSessionsDir()) && readdirSync(pinnedSessionsDir()).some((f) => f.endsWith(suffix));
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
15
31
  export async function saveSession(session) {
16
32
  const dir = sessionsDir();
17
33
  await fs.mkdir(dir, { recursive: true });
@@ -71,7 +87,8 @@ function validateSchema(parsed) {
71
87
  if (!Array.isArray(msg.content))
72
88
  continue;
73
89
  for (const block of msg.content) {
74
- if (block?.type === 'tool_use' && typeof block.partial_json === 'string') {
90
+ // Task 16 (F6): server_tool_use (tool search) streams input the same way.
91
+ if ((block?.type === 'tool_use' || block?.type === 'server_tool_use') && typeof block.partial_json === 'string') {
75
92
  if (block.input === undefined) {
76
93
  try {
77
94
  block.input = JSON.parse(block.partial_json);
@@ -84,22 +101,65 @@ function validateSchema(parsed) {
84
101
  }
85
102
  }
86
103
  }
104
+ // 2026-09-27: heal sessions poisoned by a turn cancelled mid-thinking — an
105
+ // unsigned thinking block makes every later request 400 ("Invalid
106
+ // `signature` in `thinking` block"), so --resume and `continue` failed.
107
+ stripUnsignedThinking(parsed.messages);
108
+ // Review M2 — and a thinking-only last assistant turn (cut right after it).
109
+ dropTrailingThinkingOnly(parsed.messages);
87
110
  return parsed;
88
111
  }
89
- export async function listSessions(limit = 10) {
112
+ /**
113
+ * Task 18 — a reader subagent (tools/subagent/read_agent.ts) saves its own
114
+ * `reader-*` session file; it is an internal child of a turn, not something
115
+ * the user resumes.
116
+ */
117
+ export function isReaderSessionId(id) {
118
+ return typeof id === 'string' && id.startsWith('reader-');
119
+ }
120
+ /** How many sessions `cspeach --resume` lists — the unfinished count uses the same window. */
121
+ export const RESUME_PICKER_LIMIT = 20;
122
+ /**
123
+ * The session files most likely to be the `limit` most recent, newest first
124
+ * by modification time (fix round 3, items 11d/11e). Every save rewrites the
125
+ * file, so mtime tracks last_turn_at; a margin of extra candidates absorbs
126
+ * clock / copy skew, and the caller still sorts by last_turn_at. With
127
+ * thousands of files this parses a few dozen instead of all of them (each
128
+ * full pass cost ~1.3 s at startup with 4,410 files).
129
+ */
130
+ async function recentSessionFiles(dir, limit) {
131
+ // Merge note — Task 18 reader-* child sessions are skipped by NAME here, so a
132
+ // burst of reader files can never crowd real sessions out of the window.
133
+ const files = (await fs.readdir(dir).catch(() => []))
134
+ .filter((f) => f.endsWith('.json') && !f.includes('-reader-'));
135
+ const cap = Math.max(limit * 3, 50);
136
+ if (files.length <= cap)
137
+ return files;
138
+ const stamped = await Promise.all(files.map(async (f) => {
139
+ try {
140
+ return { f, t: (await fs.stat(path.join(dir, f))).mtimeMs };
141
+ }
142
+ catch {
143
+ return { f, t: 0 };
144
+ }
145
+ }));
146
+ stamped.sort((a, b) => b.t - a.t);
147
+ return stamped.slice(0, cap).map((x) => x.f);
148
+ }
149
+ export async function listSessions(limit = 10, opts = {}) {
90
150
  // NOTE: bypasses validateSchema() for performance — reads JSON directly via
91
151
  // JSON.parse + cast. Any new SessionState field accessed here must be
92
152
  // defaulted at the read site (e.g. `?? 'skill'`) because pre-chunk-4A
93
153
  // serialised sessions on disk lack the field.
94
154
  const dir = sessionsDir();
95
- const files = await fs.readdir(dir).catch(() => []);
155
+ const files = await recentSessionFiles(dir, limit);
96
156
  const results = [];
97
157
  for (const f of files) {
98
- if (!f.endsWith('.json'))
99
- continue;
100
158
  try {
101
159
  const raw = await fs.readFile(path.join(dir, f), 'utf-8');
102
160
  const parsed = JSON.parse(raw);
161
+ if (isReaderSessionId(parsed.id))
162
+ continue; // Task 18 — internal child, never resumed
103
163
  const messages = parsed.messages ?? [];
104
164
  // First user message that's a plain string (not a tool_result array) — the
105
165
  // most recent prompt text that initiated a turn. We pick the LAST such
@@ -113,7 +173,9 @@ export async function listSessions(limit = 10) {
113
173
  // Strip <session_context>...</session_context> wrapper that the
114
174
  // first turn injects, leaving only the user-typed prompt visible
115
175
  // in the picker.
116
- const stripped = text.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim();
176
+ // (fix round 3: every injected block, e.g. <project_context>, and
177
+ // newlines — they broke the picker's table row.)
178
+ const stripped = promptPreviewText(text);
117
179
  promptSnippet = (stripped || '<empty>').slice(0, 80);
118
180
  break;
119
181
  }
@@ -135,32 +197,26 @@ export async function listSessions(limit = 10) {
135
197
  catch { /* skip corrupt */ }
136
198
  }
137
199
  results.sort((a, b) => b.lastTurnAt.localeCompare(a.lastTurnAt));
138
- return results.slice(0, limit);
200
+ // Round 6 review M1 — the picker drops 0-turn sessions (unless interrupted)
201
+ // BEFORE the limit, so a pile of start-then-quit files cannot crowd out
202
+ // the sessions worth resuming.
203
+ const kept = opts.skipEmpty ? results.filter((r) => r.userTurnCount > 0 || r.interrupted) : results;
204
+ return kept.slice(0, limit);
139
205
  }
140
206
  /**
141
- * Count interrupted sessions for the startup banner. Reads only enough of each
142
- * file to find the turnInterrupted flag, so it scales to many sessions without
143
- * a full parse + sort pass. Returns 0 on any IO failure (best-effort discovery).
207
+ * Count interrupted sessions for the startup banner — among the sessions the
208
+ * `--resume` picker can show (RESUME_PICKER_LIMIT most recent), so the
209
+ * "! N unfinished sessions" notice never points at sessions the picker does
210
+ * not list (fix round 3, item 11d). Returns 0 on any IO failure.
144
211
  */
145
212
  export async function countInterruptedSessions() {
146
- // NOTE: bypasses validateSchema() (same as listSessions). Any new
147
- // SessionState field accessed here must be defaulted at the read site.
148
213
  try {
149
- const dir = sessionsDir();
150
- const files = await fs.readdir(dir).catch(() => []);
151
- let count = 0;
152
- for (const f of files) {
153
- if (!f.endsWith('.json'))
154
- continue;
155
- try {
156
- const raw = await fs.readFile(path.join(dir, f), 'utf-8');
157
- const parsed = JSON.parse(raw);
158
- if (parsed.turnInterrupted === true)
159
- count++;
160
- }
161
- catch { /* skip corrupt */ }
162
- }
163
- return count;
214
+ // Round 8 item 7 — a turn the USER stopped (Ctrl+C, /exit mid-turn) is
215
+ // not unfinished work: counting it kept "! 1 unfinished session" on every
216
+ // start until that session happened to finish a turn. The picker still
217
+ // lists it as "Stopped by you".
218
+ return (await listSessions(RESUME_PICKER_LIMIT))
219
+ .filter((s) => s.interrupted && !isUserStopReason(s.interruptedReason)).length;
164
220
  }
165
221
  catch {
166
222
  return 0;
@@ -21,3 +21,24 @@ export function userPromptText(m) {
21
21
  export function isUserPrompt(m) {
22
22
  return userPromptText(m) !== null;
23
23
  }
24
+ /**
25
+ * Tags of the context blocks the agent loop injects into a user message
26
+ * (`<session_context>` from the intent extractor / SAP system info,
27
+ * `<project_context>` from CSPEACH_PROJECT_CONTEXT). The user never typed
28
+ * them, so a prompt preview must never show them (fix round 3, item 2).
29
+ */
30
+ const INJECTED_CONTEXT_TAGS = ['session_context', 'project_context'];
31
+ /**
32
+ * One-line preview of what the user typed: injected context blocks removed,
33
+ * `<attached file="x">…</attached>` expansions shown as the original `@x`
34
+ * token, and all whitespace runs (newlines included) collapsed to one space.
35
+ * Used by the resume picker and the resumed-session recap line.
36
+ */
37
+ export function promptPreviewText(text) {
38
+ let out = text;
39
+ for (const tag of INJECTED_CONTEXT_TAGS) {
40
+ out = out.replace(new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, 'g'), ' ');
41
+ }
42
+ out = out.replace(/<attached file="([^"]*)">[\s\S]*?<\/attached>/g, (_m, f) => `@${f}`);
43
+ return out.replace(/\s+/g, ' ').trim();
44
+ }
@@ -135,6 +135,13 @@ export const SKILL_CATALOG = [
135
135
  whenToUse: `pick this for a freestyle UI5 frontend on a published OData service (not /abap-rap — that's the backend; produces local files, not ADT objects)`,
136
136
  asks: 'which service and what UI? e.g. "list + object page on ZUI_LAS_INSPECTIONLOG_O4"',
137
137
  },
138
+ {
139
+ name: 'abap-cap',
140
+ category: 'Create / Generate',
141
+ description: 'Build a CAP (Node.js) app on the laptop from a spec — CDS domain model, service, handlers, Fiori Elements annotations, run it locally with cds watch and prove it with the Fiori checks. Side-by-side extensions, not on-stack ABAP.',
142
+ whenToUse: `pick this for a CAP / BTP side-by-side app (cds, Node.js), not ABAP — on-stack RAP is /abap-rap; a UI on an existing OData service is /abap-fiori-build`,
143
+ asks: 'what should the app manage? e.g. "suppliers and their certificates, with draft editing"',
144
+ },
138
145
  {
139
146
  name: 'abap-data-model',
140
147
  category: 'Create / Generate',