@cspeach/cli 1.1.50 → 1.1.52

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 (100) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +30 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +314 -44
  5. package/dist/agent/provider-shape.js +8 -4
  6. package/dist/agent/providers/ai-hub-provider.js +2 -2
  7. package/dist/agent/providers/azure-provider.js +2 -2
  8. package/dist/agent/providers/byok-provider.js +2 -2
  9. package/dist/agent/providers/local-provider.js +2 -2
  10. package/dist/agent/repair-partial.js +12 -0
  11. package/dist/agent/skill-block-key.js +134 -0
  12. package/dist/agent/steering-queue.js +50 -14
  13. package/dist/agent/system-prompt.js +22 -0
  14. package/dist/agent/tool-dispatch.js +19 -7
  15. package/dist/agent/turn-watchdog.js +1 -1
  16. package/dist/approvals/advisory-render.js +6 -3
  17. package/dist/approvals/approval-prompt.js +9 -4
  18. package/dist/approvals/op-labels.js +14 -0
  19. package/dist/approvals/plan-grant.js +8 -0
  20. package/dist/approvals/render.js +19 -8
  21. package/dist/approvals/risk-floor.js +32 -4
  22. package/dist/approvals/visible-changes.js +50 -0
  23. package/dist/cli.js +11 -3
  24. package/dist/commands/compact.js +12 -1
  25. package/dist/commands/config-set.js +14 -1
  26. package/dist/commands/config-show.js +16 -4
  27. package/dist/commands/export-audit.js +26 -3
  28. package/dist/commands/export-conversation.js +346 -0
  29. package/dist/commands/finish-check-guard.js +124 -0
  30. package/dist/commands/help.js +3 -0
  31. package/dist/commands/plan-boundary.js +5 -0
  32. package/dist/commands/plan-continue.js +11 -0
  33. package/dist/commands/plan-reground.js +27 -4
  34. package/dist/commands/plan-resume.js +111 -1
  35. package/dist/commands/plan-startup.js +5 -1
  36. package/dist/config/loader.js +5 -1
  37. package/dist/cost/cost-log.js +6 -0
  38. package/dist/cost/system-block.js +6 -0
  39. package/dist/docx/doc-meta.js +9 -1
  40. package/dist/docx/markdown-to-docx.js +14 -1
  41. package/dist/lane/app-chain.js +24 -1
  42. package/dist/models/server-config.js +1 -1
  43. package/dist/projects/document-save.js +65 -8
  44. package/dist/projects/document-skills.js +32 -1
  45. package/dist/projects/save-command.js +5 -1
  46. package/dist/projects/yes-no.js +5 -1
  47. package/dist/renderer/change-card.js +3 -1
  48. package/dist/renderer/steering-echo.js +53 -8
  49. package/dist/renderer/tool-labels.js +3 -1
  50. package/dist/repl/builtin-commands.js +1 -1
  51. package/dist/repl/chat-lane.js +5 -2
  52. package/dist/repl/inline-mode.js +51 -0
  53. package/dist/repl/leftover.js +90 -0
  54. package/dist/repl/reroute-turn.js +7 -1
  55. package/dist/repl/slash-completer.js +4 -0
  56. package/dist/repl/turn-stop.js +64 -0
  57. package/dist/repl.js +178 -32
  58. package/dist/router/classifier.js +1 -1
  59. package/dist/sap/reachability-state.js +7 -0
  60. package/dist/sap/unreachable.js +2 -2
  61. package/dist/session/cli-record.js +165 -0
  62. package/dist/session/fresh-start.js +4 -0
  63. package/dist/session/queued-step.js +3 -1
  64. package/dist/session/user-prompt.js +10 -1
  65. package/dist/skills/manifest-client.js +3 -2
  66. package/dist/skills/preamble.js +6 -4
  67. package/dist/skills/source-authed.js +3 -2
  68. package/dist/tools/approval.js +37 -7
  69. package/dist/tools/dispatch-skill.js +1 -1
  70. package/dist/tools/hand-off.js +49 -7
  71. package/dist/tools/lane/app-lane-resolve.js +9 -0
  72. package/dist/tools/method-only-change.js +241 -0
  73. package/dist/tools/rap/skeleton-resolve.js +4 -0
  74. package/dist/tools/sap-write.js +78 -3
  75. package/dist/tools/snapshot.js +7 -2
  76. package/dist/tools/subagent/agent_run.js +14 -0
  77. package/dist/tools/tool-loading.js +16 -8
  78. package/dist/tools/transport-fit-check.js +70 -0
  79. package/dist/tools/transport.js +17 -8
  80. package/dist/tools/write-pipeline.js +46 -5
  81. package/dist/ui/app.js +112 -13
  82. package/dist/ui/approval-modal.js +11 -6
  83. package/dist/ui/ascii-chars.js +21 -3
  84. package/dist/ui/card-slot.js +18 -1
  85. package/dist/ui/footer.js +128 -7
  86. package/dist/ui/interrupt.js +35 -1
  87. package/dist/ui/live-area.js +21 -4
  88. package/dist/ui/queue-box-emitter.js +14 -0
  89. package/dist/ui/rewind-panel.js +2 -1
  90. package/dist/ui/session-timeline.js +2 -1
  91. package/dist/ui/text-input.js +31 -2
  92. package/dist/ui/widgets/bar-chart.js +2 -1
  93. package/dist/ui/widgets/confirm-card.js +2 -1
  94. package/dist/ui/widgets/dep-graph.js +2 -1
  95. package/dist/ui/widgets/diff-viewer.js +2 -1
  96. package/dist/ui/widgets/question-card.js +2 -1
  97. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  98. package/dist/ui/widgets/stack-frames.js +2 -1
  99. package/dist/util/connect-retry.js +65 -0
  100. package/package.json +1 -1
package/dist/repl.js CHANGED
@@ -30,6 +30,7 @@ import { createDispatchSlot, queuedStepView } from './session/queued-step.js';
30
30
  import { continueSkillFor } from './repl/continue-skill.js';
31
31
  import { applyResumedSkill } from './router/resume-routing.js';
32
32
  import { ACK_PHRASES, wantsContinuation, isConversationalAck, decideLane, turnStatusLabel, applyLaneTools, chatFallbackOrigin } from './repl/chat-lane.js';
33
+ import { inlineActive, inlineServed, isChatLaneProcess } from './repl/inline-mode.js';
33
34
  import { CHAT_SKILL } from './agent/chat-prompt.js';
34
35
  import { setAssistantStyle } from './agent/style-state.js';
35
36
  import { setAssistantLevel } from './agent/level-state.js';
@@ -45,7 +46,7 @@ import { startSkillsCacheWarmup } from './skills/source-authed.js';
45
46
  import { formatLoginBanner, signInFragment } from './ui/login-banner.js';
46
47
  import { printStartupBanner, printKeytarFallbackBanner, keychainFallbackNotice } from './renderer/banners.js';
47
48
  import { paint } from './renderer/theme.js';
48
- import { steeringEchoLine, classifyMidTurnLine, midTurnSlashHint } from './renderer/steering-echo.js';
49
+ import { classifyMidTurnLine, midTurnSlashHint } from './renderer/steering-echo.js';
49
50
  import { runAddSapWizard } from './sap/onboarding.js';
50
51
  import { getConnection } from './sap/connection-manager.js';
51
52
  import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
@@ -154,6 +155,9 @@ import { getEffectiveWriteMode, runModeCommand } from './repl/mode-cycle.js';
154
155
  import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-menu.js';
155
156
  import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
156
157
  import { createRestoreGuard } from './repl/restore-guard.js';
158
+ import { createLeftoverRunner, takeUnsentReport, parseSubmitted } from './repl/leftover.js';
159
+ import { createTurnStop } from './repl/turn-stop.js';
160
+ import { requestQueueToBox } from './ui/queue-box-emitter.js';
157
161
  import { gracefulExit } from './lib/graceful-exit.js';
158
162
  import { endSapSessionAfterTurn } from './sap/session-end.js';
159
163
  import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
@@ -208,6 +212,17 @@ import './tools/subagent/schedule_draft_create.js';
208
212
  import './tools/subagent/agent_run.js';
209
213
  import './tools/subagent/read_agent.js';
210
214
  import { folderTag, currentFolderKey } from './session/project-match.js';
215
+ import { bindCliCardSession } from './session/cli-record.js';
216
+ /** /export docx conversation (2026-10-23) — the origin of a turn CSPeach started itself. */
217
+ async function cliTurnOrigin(planResume, queuedCmd) {
218
+ if (planResume) {
219
+ const { planStepOriginLine } = await import('./commands/plan-continue.js');
220
+ return { userMessageOrigin: { kind: 'plan-step', line: planStepOriginLine(planResume) } };
221
+ }
222
+ if (queuedCmd)
223
+ return { userMessageOrigin: { kind: 'queued', line: `CSPeach ran the next step: ${queuedCmd.trim()}` } };
224
+ return {};
225
+ }
211
226
  const HISTORY_SIZE = 500;
212
227
  // Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
213
228
  // the single writer shared with the Ink Footer (Task 8, ux-wave1).
@@ -793,6 +808,8 @@ export async function runRepl(opts) {
793
808
  // served config), marks it 'predicted', saves, then stays pinned. Every
794
809
  // resume entry (picker, --resume <id>) lands here via session/resume.ts.
795
810
  const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg), folderTag(process.cwd()));
811
+ // /export docx conversation — the plan startup, lane and journey cards are recorded on this session.
812
+ bindCliCardSession(session);
796
813
  // Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
797
814
  let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
798
815
  // Round 7 item 8c — the footer shows a resumed session's spend at once.
@@ -1066,13 +1083,47 @@ export async function runRepl(opts) {
1066
1083
  : pendingCancelLine(phase, exiting, queuedWorkLabel(pendingDispatchAccessor.get()));
1067
1084
  chunkEmitter.emit('chunk', '\n' + formatInfo(line));
1068
1085
  };
1086
+ // LT-15 Feature 4 — messages still waiting when a dispatch ends: run them
1087
+ // as the next turn (normal end), put them back in the box (Ctrl+C, an
1088
+ // error), let a queued hop read them first, or report them at exit.
1089
+ // Flags are per dispatch: set below, read + reset by settle() in
1090
+ // handleSubmit's finally and at a restore's end (plan review I4).
1091
+ const leftover = createLeftoverRunner({
1092
+ isBusy: () => turnStatusEmitter.isBusy(),
1093
+ isDone: () => done,
1094
+ // F4 — prose: never parsed as a slash command or @-shortcut; it goes
1095
+ // through routing like a fresh prompt (review M5), as the person's turn.
1096
+ submit: (text) => { void handleSubmit(text, { prose: true }); },
1097
+ toBox: () => { requestQueueToBox(); },
1098
+ print: (line) => chunkEmitter.emit('chunk', '\n' + line),
1099
+ schedule: (fn) => { setImmediate(fn); },
1100
+ });
1101
+ // LT-15 Task 5b — how this dispatch was stopped (Esc brake or Ctrl+C).
1102
+ // Per dispatch: read by autoDispatch (nothing queued starts after a stop)
1103
+ // and by leftover.settle, reset right after that settle and at a
1104
+ // restore's end (plan review I4). The brake reuses Ctrl+C's own abort and
1105
+ // pending cancel and never touches interruptCount (review C1).
1106
+ const turnStop = createTurnStop({
1107
+ turnActive: () => currentTurnAbort !== null,
1108
+ busy: () => turnStatusEmitter.isBusy(),
1109
+ restoreActive: () => restoreGuard.active,
1110
+ phase: () => cancelPhase(),
1111
+ canChain: () => canChainNow(),
1112
+ exitRequested: () => exitAfterTurn,
1113
+ abortTurn: () => { currentTurnAbort?.abort(); },
1114
+ cancelPending: () => { announcePendingCancel(false); },
1115
+ print: (line) => chunkEmitter.emit('chunk', '\n' + formatInfo(line)),
1116
+ });
1069
1117
  /** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
1070
1118
  // Task 13 fix round — `planHop` travels WITH the command into handleSubmit,
1071
1119
  // so a refused submit can never leave a stale hop mark behind.
1072
1120
  const autoDispatch = (cmd, planHop = false, queued = null) => {
1073
1121
  // Round 4 item 9b — the exit is already under way: start nothing new.
1074
1122
  // Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
1075
- const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending }, cmd);
1123
+ // LT-15 Task 5b (D4, 5a re-review) — nothing queued starts after a stop
1124
+ // of this dispatch (Esc or Ctrl+C); the D11 chat fallback still re-runs
1125
+ // the person's own message (review M6).
1126
+ const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending, stopped: turnStop.stopped, chatFallback: queued?.by === 'chat_fallback' }, cmd);
1076
1127
  if (decision.kind === 'skip')
1077
1128
  return;
1078
1129
  if (decision.kind === 'not-started') {
@@ -1085,6 +1136,8 @@ export async function runRepl(opts) {
1085
1136
  const notice = queued ? null : autoRoutedNotice(cmd);
1086
1137
  if (notice)
1087
1138
  chunkEmitter.emit('chunk', formatInfo(notice));
1139
+ // LT-15 D3 — the hop goes first; a waiting message is read at its round 0.
1140
+ leftover.noteDispatchScheduled();
1088
1141
  setImmediate(() => { void handleSubmit(cmd, { planHop, queued }); });
1089
1142
  };
1090
1143
  // BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
@@ -1095,6 +1148,9 @@ export async function runRepl(opts) {
1095
1148
  const trimmed = line.trim();
1096
1149
  if (!trimmed)
1097
1150
  return;
1151
+ // LT-15 F4 — a replayed waiting message is prose, also when the D11
1152
+ // chat fallback re-runs it (the flag rides on the fallback origin).
1153
+ const prose = submitOpts?.prose === true || submitOpts?.queued?.prose === true;
1098
1154
  // Round 6 review I2 — the resumed skill is offered to the FIRST submit
1099
1155
  // only (slash commands included), then cleared.
1100
1156
  const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
@@ -1103,7 +1159,10 @@ export async function runRepl(opts) {
1103
1159
  // the Footer has already persisted this submission (flattened), so
1104
1160
  // the consecutive-dup guard makes this a no-op instead of the old
1105
1161
  // double append.
1106
- appendHistoryEntry(trimmed);
1162
+ // LT-15 F4 — a replayed waiting message is already in history (the
1163
+ // Footer stored each one when it was typed).
1164
+ if (!prose)
1165
+ appendHistoryEntry(trimmed);
1107
1166
  // Phase A** (2026-05-16) — echo the user's prompt into Body so it
1108
1167
  // appears in the conversation history. Plus a dim divider above
1109
1168
  // the echo so consecutive turns have a clear visual break (closes
@@ -1157,16 +1216,18 @@ export async function runRepl(opts) {
1157
1216
  // session audit record (who did what, where, under which approval, at
1158
1217
  // what cost) to a markdown file in cwd and print the path.
1159
1218
  if (trimmed === '/export' || trimmed.startsWith('/export ')) {
1160
- const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
1161
- const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
1219
+ const { parseExportArgs, runExportAuditCommand, EXPORT_REPL_USAGE } = await import('./commands/export-audit.js');
1220
+ const { sub, filename, docxArgs } = parseExportArgs(trimmed.slice('/export'.length));
1162
1221
  // Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
1222
+ // 2026-10-23: no file → the last answer; `conversation` → the whole conversation.
1223
+ // The Word package loads only here: /export audit never needs it.
1163
1224
  if (sub === 'docx') {
1164
- const { docxFileFromReplArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
1165
- await runExportDocxCommand({ file: docxFileFromReplArgs(trimmed.slice('/export'.length)), docx: true, system: session.sap_system, emit: (l) => chunkEmitter.emit('chunk', l + '\n') });
1225
+ const { runExportDocxRepl } = await import('./commands/export-conversation.js');
1226
+ await runExportDocxRepl({ args: docxArgs ?? '', session, ...(signedInAs ? { person: signedInAs } : {}), emit: (l) => chunkEmitter.emit('chunk', l + '\n') });
1166
1227
  return;
1167
1228
  }
1168
1229
  if (sub !== 'audit') {
1169
- chunkEmitter.emit('chunk', chalk.dim('\nUsage: /export audit [filename] · /export docx <file>.md\n'));
1230
+ chunkEmitter.emit('chunk', chalk.dim(`\n${EXPORT_REPL_USAGE}\n`));
1170
1231
  return;
1171
1232
  }
1172
1233
  await runExportAuditCommand({
@@ -1347,7 +1408,7 @@ export async function runRepl(opts) {
1347
1408
  provider,
1348
1409
  userMessage: rr.prompt,
1349
1410
  skill: rr.skill,
1350
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1411
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !!process.stdin.isTTY, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !!process.stdin.isTTY), turnBy: lastTurnBy },
1351
1412
  chunkEmitter,
1352
1413
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
1353
1414
  autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
@@ -1356,6 +1417,11 @@ export async function runRepl(opts) {
1356
1417
  session,
1357
1418
  print: (line) => chunkEmitter.emit('chunk', line + '\n'),
1358
1419
  signal: rerouteAbort.signal,
1420
+ // LT-15 review I5 — an error end puts waiting text back in the box.
1421
+ onError: () => { if (!rerouteAbort.signal.aborted)
1422
+ leftover.noteError(); },
1423
+ // LT-15 Task 5b — an Esc brake prints one dim line, not the block.
1424
+ stopLine: () => (turnStop.brakedOnly ? formatInfo(turnStop.endLine()).trimEnd() : null),
1359
1425
  onFinally: () => {
1360
1426
  turnStatusEmitter.stop();
1361
1427
  currentTurnAbort = null;
@@ -1378,6 +1444,9 @@ export async function runRepl(opts) {
1378
1444
  todoEmitter.emit('update', []);
1379
1445
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1380
1446
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
1447
+ // LT-15 Task 4 — a message still waiting belongs to the old
1448
+ // conversation: back into the box (or reported), never carried over.
1449
+ leftover.toBoxOrReport();
1381
1450
  // 2026-10-22 — and the conversation history: the next prompt sends none of it.
1382
1451
  startFreshConversation(session);
1383
1452
  idleKeepalive.disarm(); // no idle cache ping for the old history
@@ -1450,7 +1519,9 @@ export async function runRepl(opts) {
1450
1519
  // an item in a 2+-item list in the LAST assistant message.
1451
1520
  let effectiveInput = trimmed;
1452
1521
  {
1453
- const expansion = maybeExpandNumberSelection(trimmed, lastAssistantText(session.messages));
1522
+ // LT-15 Task 4 fix round 1 — never for a replayed waiting message: a
1523
+ // "2" typed mid-turn did not answer the menu of the turn that just ended.
1524
+ const expansion = prose ? null : maybeExpandNumberSelection(trimmed, lastAssistantText(session.messages));
1454
1525
  if (expansion) {
1455
1526
  chunkEmitter.emit('chunk', chalk.dim(`${expansion.echo}\n`));
1456
1527
  effectiveInput = expansion.text;
@@ -1475,7 +1546,9 @@ export async function runRepl(opts) {
1475
1546
  return;
1476
1547
  }
1477
1548
  effectiveInput = door.input;
1478
- const parsed = parseShortcut(effectiveInput);
1549
+ // LT-15 F4 — a replayed waiting message is prose: never a slash command
1550
+ // or an @-shortcut (`/sap/bc/adt/x is slow` reaches the model as text).
1551
+ const parsed = parseSubmitted(effectiveInput, prose);
1479
1552
  // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
1480
1553
  // catalog: print the notice ("did you mean" / builtin-with-args) and
1481
1554
  // end the turn. Slash input must NEVER fall through to the classifier
@@ -1596,7 +1669,7 @@ export async function runRepl(opts) {
1596
1669
  sap_system: selectedAlias,
1597
1670
  last_skill: session.last_skill ?? null,
1598
1671
  last_object: extractedObject ?? session.last_object ?? null,
1599
- }, { signal: routingCtl.signal });
1672
+ }, { signal: routingCtl.signal, prose });
1600
1673
  }
1601
1674
  catch (err) {
1602
1675
  if (!routingCtl.signal.aborted) {
@@ -2041,7 +2114,8 @@ export async function runRepl(opts) {
2041
2114
  provider,
2042
2115
  userMessage: prompt,
2043
2116
  skill: skill,
2044
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2117
+ // Stage 1A — inline is decided here, at every submit, from today's inputs (never cached).
2118
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !!process.stdin.isTTY, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !!process.stdin.isTTY), turnBy: lastTurnBy },
2045
2119
  chunkEmitter,
2046
2120
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2047
2121
  explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
@@ -2051,6 +2125,8 @@ export async function runRepl(opts) {
2051
2125
  // turns (their post-turn persist indexes messages from turn start).
2052
2126
  autoCompact: planResume === null && planContinue === null,
2053
2127
  delegateSkill: planPhaseDelegate,
2128
+ // /export docx conversation — a plan step or a queued step is CSPeach's words, not the person's.
2129
+ ...(await cliTurnOrigin(planResume, submitOpts?.queued ? line : null)),
2054
2130
  // A4 — undefined on every non-plan turn (and when tiering is off).
2055
2131
  modelOverride: planModelOverride,
2056
2132
  // Task 9 — a tier-down, never the session role (proxy must not enforce it).
@@ -2117,7 +2193,7 @@ export async function runRepl(opts) {
2117
2193
  // never throw ChatUnavailableError again.
2118
2194
  laneSetting = 'skills';
2119
2195
  sapStateStore.update({ chatLane: false });
2120
- pendingDispatchAccessor.set(trimmed, chatFallbackOrigin());
2196
+ pendingDispatchAccessor.set(trimmed, chatFallbackOrigin(prose));
2121
2197
  }
2122
2198
  else {
2123
2199
  // 2026-09-24 — print the turn-error block INTO the conversation (it
@@ -2127,13 +2203,25 @@ export async function runRepl(opts) {
2127
2203
  // "type continue" (the partial turn is already saved).
2128
2204
  const { renderTurnError } = await import('./agent/turn-error-ux.js');
2129
2205
  turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
2130
- await renderTurnError({
2131
- err,
2132
- session,
2133
- userInterrupted: turnCancelledByUser,
2134
- inSession: true,
2135
- print: (line) => chunkEmitter.emit('chunk', line + '\n'),
2136
- });
2206
+ // LT-15 review I5 — an error end (not a stop): waiting text goes back
2207
+ // into the box, nothing runs by itself.
2208
+ if (!turnCancelledByUser)
2209
+ leftover.noteError();
2210
+ if (turnCancelledByUser && turnStop.brakedOnly) {
2211
+ // LT-15 Task 5b — an Esc brake: one dim line in place of the
2212
+ // "type continue" block. The loop already saved the turn as
2213
+ // interrupted, and a journey stays paused.
2214
+ chunkEmitter.emit('chunk', '\n' + formatInfo(turnStop.endLine()));
2215
+ }
2216
+ else {
2217
+ await renderTurnError({
2218
+ err,
2219
+ session,
2220
+ userInterrupted: turnCancelledByUser,
2221
+ inSession: true,
2222
+ print: (line) => chunkEmitter.emit('chunk', line + '\n'),
2223
+ });
2224
+ }
2137
2225
  }
2138
2226
  }
2139
2227
  finally {
@@ -2426,21 +2514,23 @@ export async function runRepl(opts) {
2426
2514
  chunkEmitter.emit('chunk', midTurnSlashHint(kind.command));
2427
2515
  return;
2428
2516
  }
2429
- if (t.length > 0) {
2517
+ // LT-15 Feature 1 (D5) — no scrollback receipt; the App's queue row shows it.
2518
+ if (t.length > 0)
2430
2519
  enqueueSteering(t);
2431
- chunkEmitter.emit('chunk', steeringEchoLine(t));
2432
- }
2433
2520
  return;
2434
2521
  }
2435
2522
  idleKeepalive.disarm(); // Task 15 — a submit ends the idle period
2436
2523
  turnStatusEmitter.reserve();
2437
2524
  postTurn = false;
2438
2525
  postTurnMayChain = false;
2526
+ // LT-15 review I5 — a dispatch that threw ended by an error.
2527
+ let threw = true;
2439
2528
  try {
2440
2529
  // Round 4 item 9b — the exit path waits (capped) for this to finish.
2441
2530
  const run = dispatchSubmit(line, submitOpts);
2442
2531
  dispatchTail = run;
2443
2532
  await run;
2533
+ threw = false;
2444
2534
  }
2445
2535
  finally {
2446
2536
  turnStatusEmitter.release();
@@ -2462,6 +2552,14 @@ export async function runRepl(opts) {
2462
2552
  // the submit ended in an exit (merge: UI round-3 /exit mid-turn).
2463
2553
  if (!done)
2464
2554
  idleKeepalive.armAfterTurn(session);
2555
+ // LT-15 Feature 4 — after release(): messages still waiting run as the
2556
+ // next turn (on the next tick, drained there), go back into the box,
2557
+ // wait for a queued hop, or are left for the exit report.
2558
+ if (threw)
2559
+ leftover.noteError();
2560
+ leftover.settle({ exitRequested: exitAfterTurn, braked: turnStop.braked });
2561
+ // LT-15 Task 5b (I4) — the stop belongs to this dispatch only.
2562
+ turnStop.reset();
2465
2563
  }
2466
2564
  };
2467
2565
  // Fix round 3 (item 5) — `/exit` / `/quit` typed while a turn runs:
@@ -2478,6 +2576,9 @@ export async function runRepl(opts) {
2478
2576
  cancelAnnouncedPhase = null;
2479
2577
  pendingCancel.clear();
2480
2578
  interruptCount = 0;
2579
+ // LT-15 Feature 4 — a restore's end decides like a normal turn end.
2580
+ leftover.settle({ exitRequested: exitAfterTurn, braked: turnStop.braked });
2581
+ turnStop.reset();
2481
2582
  },
2482
2583
  exit: () => { done = true; if (resolveDone)
2483
2584
  resolveDone(); },
@@ -2515,6 +2616,10 @@ export async function runRepl(opts) {
2515
2616
  let idleInterruptAt = 0;
2516
2617
  const onInterrupt = () => {
2517
2618
  const now = Date.now();
2619
+ // LT-15 D1 — Ctrl+C during a dispatch: waiting messages go back into the
2620
+ // box at this dispatch's end; nothing runs by itself.
2621
+ if (turnStatusEmitter.isBusy())
2622
+ leftover.noteCtrlC();
2518
2623
  const action = decideInterrupt({
2519
2624
  turnActive: currentTurnAbort !== null,
2520
2625
  // Review I3 — routing / prep in flight without a controller yet is
@@ -2528,18 +2633,30 @@ export async function runRepl(opts) {
2528
2633
  });
2529
2634
  if (action === 'abort-turn') {
2530
2635
  interruptCount++;
2531
- chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · saving the work done so far'));
2636
+ turnStop.noteCtrlCStop(); // LT-15 Task 5b — nothing queued starts after it
2637
+ // After an Esc the stop line is already on screen: not repeated (C1).
2638
+ if (!turnStop.braked)
2639
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · saving the work done so far'));
2532
2640
  currentTurnAbort.abort();
2641
+ return 'stopped'; // LT-15 D1 — App puts the waiting text back in the box now
2533
2642
  }
2534
2643
  else if (action === 'cancel-pending') {
2535
2644
  interruptCount++;
2645
+ turnStop.noteCtrlCStop();
2536
2646
  announcePendingCancel(false);
2647
+ return 'stopped';
2537
2648
  }
2538
2649
  else if (action === 'force-exit') {
2539
2650
  // Second Ctrl+C during the same turn — give up and exit.
2540
2651
  chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting · second Ctrl+C'));
2541
2652
  unmount();
2542
2653
  process.stdout.write('\x1b[?25h'); // restore cursor
2654
+ // LT-15 Feature 4 — say how many waiting messages were never sent.
2655
+ {
2656
+ const unsent = takeUnsentReport();
2657
+ if (unsent)
2658
+ console.log(unsent.trimEnd());
2659
+ }
2543
2660
  printGoodbye(session);
2544
2661
  // Final review B M-2 — through gracefulExit (fetch drain, capped).
2545
2662
  // Re-review item 3 — mark the session done first, so nothing queued
@@ -2559,6 +2676,18 @@ export async function runRepl(opts) {
2559
2676
  resolveDone();
2560
2677
  }
2561
2678
  };
2679
+ // LT-15 Task 5b — Esc while working (the App calls this only while busy
2680
+ // and when no card, held draft or Ctrl+R search owns the Esc). The brake
2681
+ // decides with decideEscape and reuses Ctrl+C's own stop.
2682
+ const onBrake = (ui) => {
2683
+ turnStop.press(ui);
2684
+ };
2685
+ // LT-15 Revision 2 — Esc / [c] on the approval card (or Esc / Cancel on
2686
+ // the plan gate card) is the same safe stop as the Esc brake. The card
2687
+ // still answers cancel-turn, so nothing from it is written or minted.
2688
+ const onCardStop = () => {
2689
+ turnStop.stopFromCard();
2690
+ };
2562
2691
  // Wave1 live-fix (Bug 2) — bracketed paste for the Ink path. Windows
2563
2692
  // Terminal delivers pasted newlines as `\r`, and a `\r`-only chunk
2564
2693
  // parses as key.return (ink parse-keypress.js:145-148) → every pasted
@@ -2623,6 +2752,8 @@ export async function runRepl(opts) {
2623
2752
  sapAlias: selectedAlias,
2624
2753
  onInterrupt,
2625
2754
  onExitRequest,
2755
+ onBrake,
2756
+ onCardStop,
2626
2757
  }), {
2627
2758
  // Bug 2 (wave1 live-fix) — Ink reads from the paste guard, not
2628
2759
  // process.stdin directly. The guard satisfies Ink's full stdin
@@ -2701,6 +2832,13 @@ export async function runRepl(opts) {
2701
2832
  if (exitTail === 'timeout') {
2702
2833
  console.log(formatAttention({ text: 'Post-turn work did not finish', fact: 'the session file may be incomplete' }).trimEnd());
2703
2834
  }
2835
+ // LT-15 Feature 4 — `/exit` (or the idle exit) with messages still waiting:
2836
+ // they are not sent; say how many.
2837
+ {
2838
+ const unsent = takeUnsentReport();
2839
+ if (unsent)
2840
+ console.log(unsent.trimEnd());
2841
+ }
2704
2842
  printGoodbye(session);
2705
2843
  return 0;
2706
2844
  }
@@ -3026,6 +3164,8 @@ export async function runRepl(opts) {
3026
3164
  // Task 13 — classic in-run hop flag: set by the loop-top consumer from planHops.take().
3027
3165
  let classicPlanHop = false;
3028
3166
  let classicLineBy = 'person';
3167
+ /** The command CSPeach queued for this line (null when the person typed it). */
3168
+ let classicQueuedCmd = null;
3029
3169
  while (true) {
3030
3170
  // Re-attach the bare-Esc detector every iteration. Inquirer prompts
3031
3171
  // (slash-picker, approval, ask_question, safety-confirm) remove
@@ -3042,6 +3182,7 @@ export async function runRepl(opts) {
3042
3182
  const classicQueued = pendingDispatchAccessor.take();
3043
3183
  // Task 3 — who started this line: the CSPeach source that queued it, else the person.
3044
3184
  classicLineBy = classicQueued?.origin?.by ?? 'person';
3185
+ classicQueuedCmd = classicQueued?.origin ? classicQueued.cmd : null;
3045
3186
  if (classicQueued) {
3046
3187
  const dispatchedCmd = classicQueued.cmd;
3047
3188
  // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
@@ -3197,16 +3338,18 @@ export async function runRepl(opts) {
3197
3338
  // Task 4 (ux-wave3) — classic-mode /export audit [filename]. Mirrors the
3198
3339
  // Ink handler: build the signable audit record, write it to cwd, print path.
3199
3340
  if (trimmed === '/export' || trimmed.startsWith('/export ')) {
3200
- const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
3201
- const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
3341
+ const { parseExportArgs, runExportAuditCommand, EXPORT_REPL_USAGE } = await import('./commands/export-audit.js');
3342
+ const { sub, filename, docxArgs } = parseExportArgs(trimmed.slice('/export'.length));
3202
3343
  // Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
3344
+ // 2026-10-23: no file → the last answer; `conversation` → the whole conversation.
3345
+ // The Word package loads only here: /export audit never needs it.
3203
3346
  if (sub === 'docx') {
3204
- const { docxFileFromReplArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
3205
- await runExportDocxCommand({ file: docxFileFromReplArgs(trimmed.slice('/export'.length)), docx: true, system: session.sap_system, emit: (l) => console.log(l) });
3347
+ const { runExportDocxRepl } = await import('./commands/export-conversation.js');
3348
+ await runExportDocxRepl({ args: docxArgs ?? '', session, ...(signedInAs ? { person: signedInAs } : {}), emit: (l) => console.log(l) });
3206
3349
  continue;
3207
3350
  }
3208
3351
  if (sub !== 'audit') {
3209
- console.log(chalk.dim('Usage: /export audit [filename] · /export docx <file>.md'));
3352
+ console.log(chalk.dim(EXPORT_REPL_USAGE));
3210
3353
  continue;
3211
3354
  }
3212
3355
  await runExportAuditCommand({
@@ -3382,7 +3525,7 @@ export async function runRepl(opts) {
3382
3525
  provider,
3383
3526
  userMessage: rr.prompt,
3384
3527
  skill: rr.skill,
3385
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
3528
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !pipedStdin, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !pipedStdin), turnBy: lastTurnBy },
3386
3529
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
3387
3530
  autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
3388
3531
  }),
@@ -4124,7 +4267,8 @@ export async function runRepl(opts) {
4124
4267
  provider,
4125
4268
  userMessage: prompt,
4126
4269
  skill: skill,
4127
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
4270
+ // Stage 1A — inline is decided here, at every submit, from today's inputs (never cached).
4271
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !pipedStdin, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !pipedStdin), turnBy: lastTurnBy },
4128
4272
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
4129
4273
  explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
4130
4274
  signal: turnAbort.signal,
@@ -4133,6 +4277,8 @@ export async function runRepl(opts) {
4133
4277
  // turns (their post-turn persist indexes messages from turn start).
4134
4278
  autoCompact: planResume === null && planContinue === null,
4135
4279
  delegateSkill: planPhaseDelegate,
4280
+ // /export docx conversation — a plan step or a queued step is CSPeach's words, not the person's.
4281
+ ...(await cliTurnOrigin(planResume, classicQueuedCmd)),
4136
4282
  // A4 — undefined on every non-plan turn (and when tiering is off).
4137
4283
  modelOverride: planModelOverride,
4138
4284
  // Task 9 — a tier-down, never the session role (proxy must not enforce it).
@@ -157,7 +157,7 @@ export async function classifyPrompt(prompt, context, opts = {}) {
157
157
  // before classification; this guard is the backstop that turns any
158
158
  // future regression into a loud error instead of a silent misroute.
159
159
  // Fires BEFORE config/key loading so no network call is ever made.
160
- if (prompt.trimStart().startsWith('/')) {
160
+ if (!opts.prose && prompt.trimStart().startsWith('/')) {
161
161
  throw new Error('slash command reached the classifier — explicit /commands must be routed before classification (CLI bug, please report)');
162
162
  }
163
163
  const cfg = await loadConfig();
@@ -7,6 +7,13 @@ let unreachable = null;
7
7
  const CODE_RE = /\b(ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|ECONNRESET|EAI_AGAIN)\b/;
8
8
  export const SAP_UNREACHABLE_DIRECTIVE = 'SAP is not reachable from this machine right now. Stop here: do not retry SAP tools and do not write the plan manifest. '
9
9
  + 'End the turn with one short sentence. CSPeach pauses the build and runs this step again when the network is back.';
10
+ /**
11
+ * LT-14 (1A fix wave) — outside a plan step, the note that rides on a SAP
12
+ * tool error when SAP did not answer. Before, the model got the bare error
13
+ * and invented network advice for the person. No pause, no flag.
14
+ */
15
+ export const SAP_UNREACHABLE_NOTE = 'SAP did not answer from this machine. Do not guess why or give the person network advice. '
16
+ + "After three such failures in a row, stop and ask; offer 'SAP is reachable again; try once more' and 'Stop for now; I'll pick up later'.";
10
17
  export function noteSapUnreachable(code) {
11
18
  unreachable = code;
12
19
  }
@@ -26,6 +26,6 @@ export function unreachableMessage(alias, host, port, code) {
26
26
  return (
27
27
  // 2026-10-04: never assume a VPN — many users sit on the company network.
28
28
  `Can't reach SAP system ${alias} (${host}:${port}) from this machine — ${code}. ` +
29
- 'Check network access to the SAP system (company network or VPN), ' +
30
- 'or run with --no-sap for work that does not need SAP.');
29
+ 'Check network access to the SAP system. ' +
30
+ 'For work that does not need SAP, run with --no-sap.');
31
31
  }