@cspeach/cli 1.1.18 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +58 -21
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. package/vitest.smoke-e2e.config.ts +20 -0
@@ -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
  }
@@ -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 });
@@ -84,19 +100,49 @@ function validateSchema(parsed) {
84
100
  }
85
101
  }
86
102
  }
103
+ // 2026-09-27: heal sessions poisoned by a turn cancelled mid-thinking — an
104
+ // unsigned thinking block makes every later request 400 ("Invalid
105
+ // `signature` in `thinking` block"), so --resume and `continue` failed.
106
+ stripUnsignedThinking(parsed.messages);
107
+ // Review M2 — and a thinking-only last assistant turn (cut right after it).
108
+ dropTrailingThinkingOnly(parsed.messages);
87
109
  return parsed;
88
110
  }
89
- export async function listSessions(limit = 10) {
111
+ /** How many sessions `cspeach --resume` lists — the unfinished count uses the same window. */
112
+ export const RESUME_PICKER_LIMIT = 20;
113
+ /**
114
+ * The session files most likely to be the `limit` most recent, newest first
115
+ * by modification time (fix round 3, items 11d/11e). Every save rewrites the
116
+ * file, so mtime tracks last_turn_at; a margin of extra candidates absorbs
117
+ * clock / copy skew, and the caller still sorts by last_turn_at. With
118
+ * thousands of files this parses a few dozen instead of all of them (each
119
+ * full pass cost ~1.3 s at startup with 4,410 files).
120
+ */
121
+ async function recentSessionFiles(dir, limit) {
122
+ const files = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
123
+ const cap = Math.max(limit * 3, 50);
124
+ if (files.length <= cap)
125
+ return files;
126
+ const stamped = await Promise.all(files.map(async (f) => {
127
+ try {
128
+ return { f, t: (await fs.stat(path.join(dir, f))).mtimeMs };
129
+ }
130
+ catch {
131
+ return { f, t: 0 };
132
+ }
133
+ }));
134
+ stamped.sort((a, b) => b.t - a.t);
135
+ return stamped.slice(0, cap).map((x) => x.f);
136
+ }
137
+ export async function listSessions(limit = 10, opts = {}) {
90
138
  // NOTE: bypasses validateSchema() for performance — reads JSON directly via
91
139
  // JSON.parse + cast. Any new SessionState field accessed here must be
92
140
  // defaulted at the read site (e.g. `?? 'skill'`) because pre-chunk-4A
93
141
  // serialised sessions on disk lack the field.
94
142
  const dir = sessionsDir();
95
- const files = await fs.readdir(dir).catch(() => []);
143
+ const files = await recentSessionFiles(dir, limit);
96
144
  const results = [];
97
145
  for (const f of files) {
98
- if (!f.endsWith('.json'))
99
- continue;
100
146
  try {
101
147
  const raw = await fs.readFile(path.join(dir, f), 'utf-8');
102
148
  const parsed = JSON.parse(raw);
@@ -113,7 +159,9 @@ export async function listSessions(limit = 10) {
113
159
  // Strip <session_context>...</session_context> wrapper that the
114
160
  // first turn injects, leaving only the user-typed prompt visible
115
161
  // in the picker.
116
- const stripped = text.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim();
162
+ // (fix round 3: every injected block, e.g. <project_context>, and
163
+ // newlines — they broke the picker's table row.)
164
+ const stripped = promptPreviewText(text);
117
165
  promptSnippet = (stripped || '<empty>').slice(0, 80);
118
166
  break;
119
167
  }
@@ -135,32 +183,26 @@ export async function listSessions(limit = 10) {
135
183
  catch { /* skip corrupt */ }
136
184
  }
137
185
  results.sort((a, b) => b.lastTurnAt.localeCompare(a.lastTurnAt));
138
- return results.slice(0, limit);
186
+ // Round 6 review M1 — the picker drops 0-turn sessions (unless interrupted)
187
+ // BEFORE the limit, so a pile of start-then-quit files cannot crowd out
188
+ // the sessions worth resuming.
189
+ const kept = opts.skipEmpty ? results.filter((r) => r.userTurnCount > 0 || r.interrupted) : results;
190
+ return kept.slice(0, limit);
139
191
  }
140
192
  /**
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).
193
+ * Count interrupted sessions for the startup banner — among the sessions the
194
+ * `--resume` picker can show (RESUME_PICKER_LIMIT most recent), so the
195
+ * "! N unfinished sessions" notice never points at sessions the picker does
196
+ * not list (fix round 3, item 11d). Returns 0 on any IO failure.
144
197
  */
145
198
  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
199
  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;
200
+ // Round 8 item 7 — a turn the USER stopped (Ctrl+C, /exit mid-turn) is
201
+ // not unfinished work: counting it kept "! 1 unfinished session" on every
202
+ // start until that session happened to finish a turn. The picker still
203
+ // lists it as "Stopped by you".
204
+ return (await listSessions(RESUME_PICKER_LIMIT))
205
+ .filter((s) => s.interrupted && !isUserStopReason(s.interruptedReason)).length;
164
206
  }
165
207
  catch {
166
208
  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',