@cspeach/cli 1.1.51 → 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 (85) 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 +208 -41
  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 +10 -2
  24. package/dist/commands/compact.js +6 -1
  25. package/dist/commands/config-set.js +13 -0
  26. package/dist/commands/config-show.js +16 -4
  27. package/dist/commands/export-conversation.js +7 -4
  28. package/dist/commands/finish-check-guard.js +124 -0
  29. package/dist/commands/plan-reground.js +27 -4
  30. package/dist/commands/plan-resume.js +111 -1
  31. package/dist/config/loader.js +5 -1
  32. package/dist/cost/cost-log.js +6 -0
  33. package/dist/cost/system-block.js +6 -0
  34. package/dist/models/server-config.js +1 -1
  35. package/dist/projects/save-command.js +5 -1
  36. package/dist/projects/yes-no.js +5 -1
  37. package/dist/renderer/change-card.js +3 -1
  38. package/dist/renderer/steering-echo.js +53 -8
  39. package/dist/renderer/tool-labels.js +3 -1
  40. package/dist/repl/chat-lane.js +5 -2
  41. package/dist/repl/inline-mode.js +51 -0
  42. package/dist/repl/leftover.js +90 -0
  43. package/dist/repl/reroute-turn.js +7 -1
  44. package/dist/repl/turn-stop.js +64 -0
  45. package/dist/repl.js +144 -22
  46. package/dist/router/classifier.js +1 -1
  47. package/dist/sap/reachability-state.js +7 -0
  48. package/dist/sap/unreachable.js +2 -2
  49. package/dist/session/queued-step.js +3 -1
  50. package/dist/session/user-prompt.js +10 -1
  51. package/dist/skills/manifest-client.js +3 -2
  52. package/dist/skills/preamble.js +6 -4
  53. package/dist/skills/source-authed.js +3 -2
  54. package/dist/tools/approval.js +37 -7
  55. package/dist/tools/dispatch-skill.js +1 -1
  56. package/dist/tools/hand-off.js +49 -7
  57. package/dist/tools/lane/app-lane-resolve.js +9 -0
  58. package/dist/tools/method-only-change.js +241 -0
  59. package/dist/tools/rap/skeleton-resolve.js +4 -0
  60. package/dist/tools/sap-write.js +78 -3
  61. package/dist/tools/snapshot.js +7 -2
  62. package/dist/tools/subagent/agent_run.js +14 -0
  63. package/dist/tools/tool-loading.js +16 -8
  64. package/dist/tools/transport-fit-check.js +70 -0
  65. package/dist/tools/transport.js +17 -8
  66. package/dist/tools/write-pipeline.js +46 -5
  67. package/dist/ui/app.js +112 -13
  68. package/dist/ui/approval-modal.js +11 -6
  69. package/dist/ui/ascii-chars.js +21 -3
  70. package/dist/ui/footer.js +128 -7
  71. package/dist/ui/interrupt.js +35 -1
  72. package/dist/ui/live-area.js +21 -4
  73. package/dist/ui/queue-box-emitter.js +14 -0
  74. package/dist/ui/rewind-panel.js +2 -1
  75. package/dist/ui/session-timeline.js +2 -1
  76. package/dist/ui/text-input.js +31 -2
  77. package/dist/ui/widgets/bar-chart.js +2 -1
  78. package/dist/ui/widgets/confirm-card.js +2 -1
  79. package/dist/ui/widgets/dep-graph.js +2 -1
  80. package/dist/ui/widgets/diff-viewer.js +2 -1
  81. package/dist/ui/widgets/question-card.js +2 -1
  82. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  83. package/dist/ui/widgets/stack-frames.js +2 -1
  84. package/dist/util/connect-retry.js +65 -0
  85. 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';
@@ -1079,13 +1083,47 @@ export async function runRepl(opts) {
1079
1083
  : pendingCancelLine(phase, exiting, queuedWorkLabel(pendingDispatchAccessor.get()));
1080
1084
  chunkEmitter.emit('chunk', '\n' + formatInfo(line));
1081
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
+ });
1082
1117
  /** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
1083
1118
  // Task 13 fix round — `planHop` travels WITH the command into handleSubmit,
1084
1119
  // so a refused submit can never leave a stale hop mark behind.
1085
1120
  const autoDispatch = (cmd, planHop = false, queued = null) => {
1086
1121
  // Round 4 item 9b — the exit is already under way: start nothing new.
1087
1122
  // Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
1088
- 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);
1089
1127
  if (decision.kind === 'skip')
1090
1128
  return;
1091
1129
  if (decision.kind === 'not-started') {
@@ -1098,6 +1136,8 @@ export async function runRepl(opts) {
1098
1136
  const notice = queued ? null : autoRoutedNotice(cmd);
1099
1137
  if (notice)
1100
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();
1101
1141
  setImmediate(() => { void handleSubmit(cmd, { planHop, queued }); });
1102
1142
  };
1103
1143
  // BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
@@ -1108,6 +1148,9 @@ export async function runRepl(opts) {
1108
1148
  const trimmed = line.trim();
1109
1149
  if (!trimmed)
1110
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;
1111
1154
  // Round 6 review I2 — the resumed skill is offered to the FIRST submit
1112
1155
  // only (slash commands included), then cleared.
1113
1156
  const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
@@ -1116,7 +1159,10 @@ export async function runRepl(opts) {
1116
1159
  // the Footer has already persisted this submission (flattened), so
1117
1160
  // the consecutive-dup guard makes this a no-op instead of the old
1118
1161
  // double append.
1119
- 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);
1120
1166
  // Phase A** (2026-05-16) — echo the user's prompt into Body so it
1121
1167
  // appears in the conversation history. Plus a dim divider above
1122
1168
  // the echo so consecutive turns have a clear visual break (closes
@@ -1362,7 +1408,7 @@ export async function runRepl(opts) {
1362
1408
  provider,
1363
1409
  userMessage: rr.prompt,
1364
1410
  skill: rr.skill,
1365
- 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 },
1366
1412
  chunkEmitter,
1367
1413
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
1368
1414
  autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
@@ -1371,6 +1417,11 @@ export async function runRepl(opts) {
1371
1417
  session,
1372
1418
  print: (line) => chunkEmitter.emit('chunk', line + '\n'),
1373
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),
1374
1425
  onFinally: () => {
1375
1426
  turnStatusEmitter.stop();
1376
1427
  currentTurnAbort = null;
@@ -1393,6 +1444,9 @@ export async function runRepl(opts) {
1393
1444
  todoEmitter.emit('update', []);
1394
1445
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1395
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();
1396
1450
  // 2026-10-22 — and the conversation history: the next prompt sends none of it.
1397
1451
  startFreshConversation(session);
1398
1452
  idleKeepalive.disarm(); // no idle cache ping for the old history
@@ -1465,7 +1519,9 @@ export async function runRepl(opts) {
1465
1519
  // an item in a 2+-item list in the LAST assistant message.
1466
1520
  let effectiveInput = trimmed;
1467
1521
  {
1468
- 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));
1469
1525
  if (expansion) {
1470
1526
  chunkEmitter.emit('chunk', chalk.dim(`${expansion.echo}\n`));
1471
1527
  effectiveInput = expansion.text;
@@ -1490,7 +1546,9 @@ export async function runRepl(opts) {
1490
1546
  return;
1491
1547
  }
1492
1548
  effectiveInput = door.input;
1493
- 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);
1494
1552
  // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
1495
1553
  // catalog: print the notice ("did you mean" / builtin-with-args) and
1496
1554
  // end the turn. Slash input must NEVER fall through to the classifier
@@ -1611,7 +1669,7 @@ export async function runRepl(opts) {
1611
1669
  sap_system: selectedAlias,
1612
1670
  last_skill: session.last_skill ?? null,
1613
1671
  last_object: extractedObject ?? session.last_object ?? null,
1614
- }, { signal: routingCtl.signal });
1672
+ }, { signal: routingCtl.signal, prose });
1615
1673
  }
1616
1674
  catch (err) {
1617
1675
  if (!routingCtl.signal.aborted) {
@@ -2056,7 +2114,8 @@ export async function runRepl(opts) {
2056
2114
  provider,
2057
2115
  userMessage: prompt,
2058
2116
  skill: skill,
2059
- 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 },
2060
2119
  chunkEmitter,
2061
2120
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2062
2121
  explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
@@ -2134,7 +2193,7 @@ export async function runRepl(opts) {
2134
2193
  // never throw ChatUnavailableError again.
2135
2194
  laneSetting = 'skills';
2136
2195
  sapStateStore.update({ chatLane: false });
2137
- pendingDispatchAccessor.set(trimmed, chatFallbackOrigin());
2196
+ pendingDispatchAccessor.set(trimmed, chatFallbackOrigin(prose));
2138
2197
  }
2139
2198
  else {
2140
2199
  // 2026-09-24 — print the turn-error block INTO the conversation (it
@@ -2144,13 +2203,25 @@ export async function runRepl(opts) {
2144
2203
  // "type continue" (the partial turn is already saved).
2145
2204
  const { renderTurnError } = await import('./agent/turn-error-ux.js');
2146
2205
  turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
2147
- await renderTurnError({
2148
- err,
2149
- session,
2150
- userInterrupted: turnCancelledByUser,
2151
- inSession: true,
2152
- print: (line) => chunkEmitter.emit('chunk', line + '\n'),
2153
- });
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
+ }
2154
2225
  }
2155
2226
  }
2156
2227
  finally {
@@ -2443,21 +2514,23 @@ export async function runRepl(opts) {
2443
2514
  chunkEmitter.emit('chunk', midTurnSlashHint(kind.command));
2444
2515
  return;
2445
2516
  }
2446
- 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)
2447
2519
  enqueueSteering(t);
2448
- chunkEmitter.emit('chunk', steeringEchoLine(t));
2449
- }
2450
2520
  return;
2451
2521
  }
2452
2522
  idleKeepalive.disarm(); // Task 15 — a submit ends the idle period
2453
2523
  turnStatusEmitter.reserve();
2454
2524
  postTurn = false;
2455
2525
  postTurnMayChain = false;
2526
+ // LT-15 review I5 — a dispatch that threw ended by an error.
2527
+ let threw = true;
2456
2528
  try {
2457
2529
  // Round 4 item 9b — the exit path waits (capped) for this to finish.
2458
2530
  const run = dispatchSubmit(line, submitOpts);
2459
2531
  dispatchTail = run;
2460
2532
  await run;
2533
+ threw = false;
2461
2534
  }
2462
2535
  finally {
2463
2536
  turnStatusEmitter.release();
@@ -2479,6 +2552,14 @@ export async function runRepl(opts) {
2479
2552
  // the submit ended in an exit (merge: UI round-3 /exit mid-turn).
2480
2553
  if (!done)
2481
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();
2482
2563
  }
2483
2564
  };
2484
2565
  // Fix round 3 (item 5) — `/exit` / `/quit` typed while a turn runs:
@@ -2495,6 +2576,9 @@ export async function runRepl(opts) {
2495
2576
  cancelAnnouncedPhase = null;
2496
2577
  pendingCancel.clear();
2497
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();
2498
2582
  },
2499
2583
  exit: () => { done = true; if (resolveDone)
2500
2584
  resolveDone(); },
@@ -2532,6 +2616,10 @@ export async function runRepl(opts) {
2532
2616
  let idleInterruptAt = 0;
2533
2617
  const onInterrupt = () => {
2534
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();
2535
2623
  const action = decideInterrupt({
2536
2624
  turnActive: currentTurnAbort !== null,
2537
2625
  // Review I3 — routing / prep in flight without a controller yet is
@@ -2545,18 +2633,30 @@ export async function runRepl(opts) {
2545
2633
  });
2546
2634
  if (action === 'abort-turn') {
2547
2635
  interruptCount++;
2548
- 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'));
2549
2640
  currentTurnAbort.abort();
2641
+ return 'stopped'; // LT-15 D1 — App puts the waiting text back in the box now
2550
2642
  }
2551
2643
  else if (action === 'cancel-pending') {
2552
2644
  interruptCount++;
2645
+ turnStop.noteCtrlCStop();
2553
2646
  announcePendingCancel(false);
2647
+ return 'stopped';
2554
2648
  }
2555
2649
  else if (action === 'force-exit') {
2556
2650
  // Second Ctrl+C during the same turn — give up and exit.
2557
2651
  chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting · second Ctrl+C'));
2558
2652
  unmount();
2559
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
+ }
2560
2660
  printGoodbye(session);
2561
2661
  // Final review B M-2 — through gracefulExit (fetch drain, capped).
2562
2662
  // Re-review item 3 — mark the session done first, so nothing queued
@@ -2576,6 +2676,18 @@ export async function runRepl(opts) {
2576
2676
  resolveDone();
2577
2677
  }
2578
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
+ };
2579
2691
  // Wave1 live-fix (Bug 2) — bracketed paste for the Ink path. Windows
2580
2692
  // Terminal delivers pasted newlines as `\r`, and a `\r`-only chunk
2581
2693
  // parses as key.return (ink parse-keypress.js:145-148) → every pasted
@@ -2640,6 +2752,8 @@ export async function runRepl(opts) {
2640
2752
  sapAlias: selectedAlias,
2641
2753
  onInterrupt,
2642
2754
  onExitRequest,
2755
+ onBrake,
2756
+ onCardStop,
2643
2757
  }), {
2644
2758
  // Bug 2 (wave1 live-fix) — Ink reads from the paste guard, not
2645
2759
  // process.stdin directly. The guard satisfies Ink's full stdin
@@ -2718,6 +2832,13 @@ export async function runRepl(opts) {
2718
2832
  if (exitTail === 'timeout') {
2719
2833
  console.log(formatAttention({ text: 'Post-turn work did not finish', fact: 'the session file may be incomplete' }).trimEnd());
2720
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
+ }
2721
2842
  printGoodbye(session);
2722
2843
  return 0;
2723
2844
  }
@@ -3404,7 +3525,7 @@ export async function runRepl(opts) {
3404
3525
  provider,
3405
3526
  userMessage: rr.prompt,
3406
3527
  skill: rr.skill,
3407
- 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 },
3408
3529
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
3409
3530
  autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
3410
3531
  }),
@@ -4146,7 +4267,8 @@ export async function runRepl(opts) {
4146
4267
  provider,
4147
4268
  userMessage: prompt,
4148
4269
  skill: skill,
4149
- 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 },
4150
4272
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
4151
4273
  explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
4152
4274
  signal: turnAbort.signal,
@@ -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
  }
@@ -45,5 +45,7 @@ export function queuedStepView(origin) {
45
45
  if (!line)
46
46
  return { echo: false, line: null };
47
47
  // D11 — the chat-fallback line is dim; painted after shownUpTo flattened it.
48
- return { echo: false, line: origin.by === 'chat_fallback' ? paint('dim', line) : line };
48
+ // LT-4 (1A fix wave, owner ruling): the hand-off arrow line is dim too, kept in scrollback.
49
+ const dim = origin.by === 'chat_fallback' || origin.by === 'hand_off';
50
+ return { echo: false, line: dim ? paint('dim', line) : line };
49
51
  }
@@ -26,8 +26,17 @@ export function isUserPrompt(m) {
26
26
  * (`<session_context>` from the intent extractor / SAP system info,
27
27
  * `<project_context>` from CSPEACH_PROJECT_CONTEXT). The user never typed
28
28
  * them, so a prompt preview must never show them (fix round 3, item 2).
29
+ * Stage 1A (M5) — `<skill>`, the playbook block an inline turn appends. The
30
+ * order matters: `<session_context>` first, so the bare `<skill>` word in a
31
+ * `<playbook>` line goes with the block it sits in — `skill` stays last.
32
+ * Shared with the conversation export (commands/export-conversation.ts).
29
33
  */
30
- const INJECTED_CONTEXT_TAGS = ['session_context', 'project_context'];
34
+ export const INJECTED_CONTEXT_TAGS = [
35
+ 'session_context', 'project_context',
36
+ // The /export docx conversation list (main, 2026-10-23): one list for both readers.
37
+ 'journey', 'lane_decision', 'system_switch', 'sap_system', 'sap_connection',
38
+ 'skill',
39
+ ];
31
40
  /**
32
41
  * One-line preview of what the user typed: injected context blocks removed,
33
42
  * `<attached file="x">…</attached>` expansions shown as the original `@x`
@@ -4,6 +4,7 @@ import { sha512 } from '@noble/hashes/sha512';
4
4
  import { bytesToHex, concatBytes } from '@noble/hashes/utils';
5
5
  import { MANIFEST_PUBLIC_KEYS } from './signing-public-key.js';
6
6
  import { canonicalise } from './canonical.js';
7
+ import { fetchWithConnectRetry } from '../util/connect-retry.js';
7
8
  // @noble/ed25519 v2 requires an ed25519 sha512 hook.
8
9
  ed.etc.sha512Sync = (...m) => sha512(concatBytes(...m));
9
10
  /**
@@ -39,7 +40,7 @@ export class ManifestClient {
39
40
  async fetchManifest() {
40
41
  let r;
41
42
  try {
42
- r = await fetch(this.manifestUrl, { signal: AbortSignal.timeout(10_000) });
43
+ r = await fetchWithConnectRetry(this.manifestUrl, () => ({ signal: AbortSignal.timeout(10_000) }));
43
44
  }
44
45
  catch (e) {
45
46
  throw new ManifestUnavailableError(`manifest fetch failed (${this.manifestUrl}): ${e.message}`);
@@ -99,7 +100,7 @@ export class ManifestClient {
99
100
  async fetchSkillBody(name, bodyUrl, opts) {
100
101
  let r;
101
102
  try {
102
- r = await fetch(bodyUrl, { signal: AbortSignal.timeout(10_000) });
103
+ r = await fetchWithConnectRetry(bodyUrl, () => ({ signal: AbortSignal.timeout(10_000) }));
103
104
  }
104
105
  catch (e) {
105
106
  throw new Error(`skill body fetch failed (${bodyUrl}): ${e.message}`);
@@ -52,8 +52,10 @@ take precedence if a skill's instruction conflicts with them.
52
52
  revise and re-submit.
53
53
 
54
54
  ## Transport discipline
55
- - All writes go into the active session transport. Do not ask the user
56
- for a transport number per change.
55
+ - All writes go into the session transport once one is set. Do not ask
56
+ the user for a transport number per change. When a create says a
57
+ transport is needed, ask the user once: a new session transport
58
+ (recommended) or one of the open requests for the package.
57
59
  - Never use \$TMP unless the user explicitly requests local-only work.
58
60
 
59
61
  ## Escalate, don't fail silently
@@ -70,6 +72,6 @@ take precedence if a skill's instruction conflicts with them.
70
72
 
71
73
  ## One skill per turn
72
74
  - If the user's request is better served by a different skill, call
73
- switch_skill with a clear reason. Do not try to do the work of another
74
- skill yourself.
75
+ dispatch_skill after the person has picked the command. Do not try to do
76
+ the work of another skill yourself.
75
77
  `;
@@ -14,6 +14,7 @@ import { sha256 } from '@noble/hashes/sha256';
14
14
  import { bytesToHex } from '@noble/hashes/utils';
15
15
  import { ManifestClient, DEFAULT_MANIFEST_URL, SkillIntegrityError, ManifestUnavailableError, } from './manifest-client.js';
16
16
  import { fileSkillsCache } from './skills-cache.js';
17
+ import { fetchWithConnectRetry } from '../util/connect-retry.js';
17
18
  // Defined next to the client that throws it (Task 8 (a): typed, never string-matched).
18
19
  export { SkillIntegrityError };
19
20
  export class SkillNotInPlanError extends Error {
@@ -69,10 +70,10 @@ async function fetchSignedInBody(baseUrl, name, expectedSha, getBearer, doFetch)
69
70
  throw new SkillSignInError();
70
71
  let r;
71
72
  try {
72
- r = await doFetch(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, {
73
+ r = await fetchWithConnectRetry(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, () => ({
73
74
  headers: { Authorization: `Bearer ${bearer}` },
74
75
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
75
- });
76
+ }), { fetchImpl: doFetch });
76
77
  }
77
78
  catch {
78
79
  throw new SkillsUnavailableError('could not reach CSPeach');
@@ -6,6 +6,14 @@ import { isHeadless, shouldUseInk } from '../renderer/tty.js';
6
6
  import { effectiveRisk, riskResultFields, typeCode } from '../approvals/risk-floor.js';
7
7
  import { objectsWithSyntaxErrors } from './syntax-state.js';
8
8
  import { noteWriteTransport } from '../repl/current-transport.js';
9
+ import { STOPPED_BEFORE_START_RESULT } from '../agent/repair-partial.js';
10
+ /**
11
+ * LT-15 D6 (F6) — after a stop (Esc or Ctrl+C set the same turn abort) no new
12
+ * card opens and no approval id is minted. The shape dispatchTool returns for
13
+ * a tool that never started (the tool_use stays paired), with a text that says
14
+ * nothing ran.
15
+ */
16
+ const stoppedBeforeCard = (ctx) => (ctx.signal?.aborted ? { content: STOPPED_BEFORE_START_RESULT, is_error: true } : null);
9
17
  /**
10
18
  * Round 8 item 6 — the footer showed "no transport" while the approved card
11
19
  * said "transport S4HK903731". An approval granted for an explicit transport
@@ -29,7 +37,7 @@ import { promptAdvisory } from '../approvals/advisory-prompt.js';
29
37
  import { markPlanGateApproved } from '../repl/rule8-detector.js';
30
38
  import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
31
39
  import { getCurrentTransport } from '../repl/current-transport.js';
32
- import { displayTransport } from '../approvals/op-labels.js';
40
+ import { displayTransport, knownTypeWord } from '../approvals/op-labels.js';
33
41
  import { canonicalApprovalObject as bareObjectName } from '../approvals/canonical.js';
34
42
  import { projectTransport, SEVERAL_REQUESTS_LABEL, transportForWrite, TRANSPORT_REQUEST } from '../register/project-transport.js';
35
43
  import { NOT_ON_THIS_RELEASE, approvalRefusedByProfile } from '../sap/profile-gate.js';
@@ -223,7 +231,8 @@ registerTool({
223
231
  type: 'object',
224
232
  properties: {
225
233
  op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release', 'run'] },
226
- object: { type: 'string' },
234
+ // P-2 (1A fix wave): a group word ('DCLs') is not an object the plan grant can match.
235
+ object: { type: 'string', description: "One real object name per row (comma-join several only in an activate row), never a group word such as 'DCLs'." },
227
236
  type: { type: 'string' },
228
237
  diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
229
238
  package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
@@ -298,6 +307,10 @@ registerTool({
298
307
  // no role configured and no session override this is exactly
299
308
  // cfg.write_mode — byte-identical behavior.
300
309
  const role = cfg.sap[ctx.sapAlias]?.role;
310
+ // LT-15 D6 — stopped during the profile read above: no advisory card, no card.
311
+ const stopA = stoppedBeforeCard(ctx);
312
+ if (stopA)
313
+ return stopA;
301
314
  // Advisory-only short-circuit — never mint approval_ids.
302
315
  // The developer applies the change manually in ADT; the AI gets an
303
316
  // ai_directive telling it not to attempt any SAP write tools.
@@ -313,11 +326,17 @@ registerTool({
313
326
  return { content: JSON.stringify(releaseRefusal), is_error: true };
314
327
  // Task 8 (B11) — the request that already holds each modified object, before any card.
315
328
  await attachOwningTransport(args.changes ?? [], ctx);
329
+ // LT-15 D6 — stopped during the reads above: no card, no nag, no id minted
330
+ // (the plan-grant and auto-approve paths below mint without a card).
331
+ const stopB = stoppedBeforeCard(ctx);
332
+ if (stopB)
333
+ return stopB;
316
334
  let sapCfg = cfg.sap[ctx.sapAlias];
317
335
  const riskCtx = {
318
336
  sapAlias: ctx.sapAlias,
319
337
  autoApprovePolicy: sapCfg?.auto_approve ?? 'never',
320
338
  syntaxErrorObjects: objectsWithSyntaxErrors(),
339
+ typeWord: knownTypeWord, // LT-10: the syntax-errors reason names the kind of object
321
340
  };
322
341
  const changes = args.changes;
323
342
  const declared = args.risk;
@@ -421,6 +440,10 @@ registerTool({
421
440
  sapCfg = cfg.sap[ctx.sapAlias];
422
441
  riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
423
442
  }
443
+ // LT-15 D6 (review M3) — stopped while the nag was up: no card after it, no id.
444
+ const stopC = stoppedBeforeCard(ctx);
445
+ if (stopC)
446
+ return stopC;
424
447
  // 1.1.31 (owner, 2026-10-07): a package or transport always gets a person's
425
448
  // yes. Live, auto_approve="low" let the model's own "low" create package
426
449
  // ZCC_FIX_PROOF and request S4HK903757 with no card. The plan-grant path
@@ -478,12 +501,14 @@ registerTool({
478
501
  // for one task" complaint. Review-each falls back to the per-change
479
502
  // gate. Cancel rejects the whole plan.
480
503
  //
481
- // Task 9 (C8): only for more than one VISIBLE change — the same count the
482
- // plan gate shows (an activate whose write is in the list is not a change
483
- // a person sees). A modify + its activate on one class gets the
484
- // per-change card only; the activate follows its approved write below.
504
+ // Task 9 (C8): only for more than one VISIBLE change (an activate whose
505
+ // write is in the list is not a change a person sees). A modify + its
506
+ // activate on one class gets the per-change card only; the activate
507
+ // follows its approved write below. LT-12: the plan gate's LIST also
508
+ // hides a modify that a create of the same object covers (planRows), so
509
+ // it may show fewer rows than this count; this count is unchanged.
485
510
  let skipPerChangeGates = false;
486
- // Raw keys on purpose: the same collapse (and count) the plan gate displays.
511
+ // Raw keys on purpose: activates collapsed, nothing else.
487
512
  const visibleCount = collapseImplicitActivates(changes).length;
488
513
  if (visibleCount > 1) {
489
514
  const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff, ctx.sapAlias);
@@ -549,6 +574,11 @@ registerTool({
549
574
  outcome = { approved: false, reason: 'sibling_write_skipped' };
550
575
  }
551
576
  else {
577
+ // LT-15 D6 (review M3) — stopped during the mint after the card before:
578
+ // the next card does not open; ids minted so far are dropped unused.
579
+ const stopD = stoppedBeforeCard(ctx);
580
+ if (stopD)
581
+ return stopD;
552
582
  outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
553
583
  }
554
584
  if (outcome.approved) {
@@ -44,7 +44,7 @@ registerTool({
44
44
  properties: {
45
45
  command: {
46
46
  type: 'string',
47
- description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`.',
47
+ description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`. Never put a transport description in it unless the person typed it.',
48
48
  },
49
49
  },
50
50
  required: ['command'],