@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/ui/footer.js CHANGED
@@ -9,7 +9,7 @@ import Fuse from 'fuse.js';
9
9
  // Task 8 (ux-wave1) — persistence lives in the shared writer.
10
10
  import { appendHistoryEntry, readHistoryEntries, flattenHistoryEntry } from '../repl/history.js';
11
11
  import { frameWidth, truncateToWidth } from '../renderer/ui-width.js';
12
- import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
12
+ import { cardBorderStyle } from './ascii-chars.js';
13
13
  import { getGlyphs } from '../renderer/glyphs.js';
14
14
  import { roleProps, selectedProps } from './role-props.js';
15
15
  import { countInputRows, expandPasteChips, nextPasteChip, INPUT_MAX_LINES } from './input-wrap.js';
@@ -25,6 +25,22 @@ function appendHistory(line, history) {
25
25
  if (appendHistoryEntry(entry))
26
26
  history.push(entry);
27
27
  }
28
+ /** LT-15 final review I1 — the second Esc must come within this to clear returned text. */
29
+ export const RETURNED_ESC_WINDOW_MS = 2000;
30
+ /** LT-15 final review I1 — the dim hint at the first Esc on returned held lines. */
31
+ export const RETURNED_ESC_HINT = 'Esc again to clear the returned message';
32
+ /**
33
+ * LT-15 (review I3) — where returned text lands in the box. The input row
34
+ * shows a newline as a space, so a multiline message goes into the held
35
+ * lines (shown as real lines, sent joined with '\n' on Enter) and only its
36
+ * last line becomes the editable value. A draft already in the box stays in
37
+ * front of it, never overwritten: its value line becomes a held line too.
38
+ */
39
+ export function placeInBox(held, value, text) {
40
+ const lines = text.split(/\r?\n/);
41
+ const last = lines.pop() ?? '';
42
+ return { held: [...held, ...(value !== '' ? [value] : []), ...lines], value: last };
43
+ }
28
44
  /** I1 (spec §7) — the prompt glyph; the brand stays on the start screen. */
29
45
  export const PROMPT_GLYPH = '›';
30
46
  const PROMPT = `${PROMPT_GLYPH} `;
@@ -38,8 +54,12 @@ const BOX_CHROME = 4;
38
54
  export function inputBoxWidth(columns) {
39
55
  return frameWidth(columns);
40
56
  }
41
- export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange, maxLines, onInputLinesChange, waitingForCard, }) {
57
+ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange, maxLines, onInputLinesChange, waitingForCard, onWithdraw, boxRef, onFuzzyChange, }) {
42
58
  const [value, setValue] = useState('');
59
+ // LT-15 (review I3) — the value as of the last render, for putInBox (called
60
+ // from outside the input handlers, so it must not read a stale closure).
61
+ const valueNowRef = useRef(value);
62
+ valueNowRef.current = value;
43
63
  const { stdout } = useStdout();
44
64
  const [, bumpOnResize] = useState(0);
45
65
  useEffect(() => {
@@ -72,9 +92,24 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
72
92
  const fuse = React.useMemo(() => new Fuse(history, { threshold: 0.4 }), [history]);
73
93
  useEffect(() => { onValueChange?.(value); }, [value, onValueChange]);
74
94
  useEffect(() => { onBufferChange?.(buffer.length); }, [buffer.length, onBufferChange]);
95
+ useEffect(() => { onFuzzyChange?.(fuzzyMode); }, [fuzzyMode, onFuzzyChange]);
75
96
  const desiredRows = countInputRows(buffer, value, wrapWidth);
76
97
  useEffect(() => { onInputLinesChange?.(desiredRows); }, [desiredRows, onInputLinesChange]);
98
+ // LT-15 final review I1 — held lines that came from putInBox and were not
99
+ // edited since. The first Esc on them only shows a hint; a second Esc
100
+ // within RETURNED_ESC_WINDOW_MS discards as today. Any edit (a key, a
101
+ // paste, a submit) makes them an ordinary draft again.
102
+ const putHeldFreshRef = useRef(false);
103
+ const escArmedAtRef = useRef(0);
104
+ // Set while the Footer handles an Esc; cleared after the current input
105
+ // burst (a microtask runs only once the synchronous drain is done).
106
+ const escOwnedRef = useRef(false);
107
+ const handleChange = useCallback((v) => {
108
+ putHeldFreshRef.current = false;
109
+ setValue(v);
110
+ }, []);
77
111
  const handlePasteLines = useCallback((lines) => {
112
+ putHeldFreshRef.current = false;
78
113
  updateBuffer([...bufferRef.current, ...lines]);
79
114
  }, [updateBuffer]);
80
115
  const handleLargePaste = useCallback((text) => {
@@ -95,12 +130,73 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
95
130
  setFuzzyIdx(0);
96
131
  }
97
132
  }, [fuzzyQuery, fuzzyMode, fuse]);
133
+ // LT-15 Feature 2 / review I3 — text put back into the box.
134
+ // `boxPutPendingRef`: set by putInBox, cleared by the first commit after
135
+ // it (that render already holds the put's state updates, and the input
136
+ // handlers are re-bound with the new value by then). A second ↑ in the same
137
+ // input burst still sees the old (empty) value in its closure; without this
138
+ // it would take a second message and overwrite the first one in the box.
139
+ // The token is captured during render, so an older commit's effect can
140
+ // never clear a newer put, and nothing can leave it set for good.
141
+ // `skipHistRef`: the history copy of the text just put back (the Footer
142
+ // stored it in history when it was first sent). The next ↑ skips it, so ↑
143
+ // does not show the same text again (controller ruling I2.4).
144
+ const boxPutPendingRef = useRef(null);
145
+ const skipHistRef = useRef(null);
146
+ const putPendingAtRender = boxPutPendingRef.current;
147
+ useEffect(() => {
148
+ if (putPendingAtRender !== null && boxPutPendingRef.current === putPendingAtRender)
149
+ boxPutPendingRef.current = null;
150
+ });
151
+ // Fix round 1 (C1) — the field's synchronous handle (null while the box is
152
+ // disabled or Ctrl+R search shows; the field then mounts with the state).
153
+ const inputHandleRef = useRef(null);
154
+ const putInBox = useCallback((text) => {
155
+ // LT-15 Task 4 — the field's live text when it has one (a key typed just
156
+ // before an outside put must stay in front), else the last render's value.
157
+ const next = placeInBox(bufferRef.current, inputHandleRef.current?.getValueNow() ?? valueNowRef.current, text);
158
+ updateBuffer(next.held);
159
+ inputHandleRef.current?.setValueNow(next.value);
160
+ setValue(next.value);
161
+ valueNowRef.current = next.value;
162
+ setHistIdx(-1);
163
+ skipHistRef.current = flattenHistoryEntry(text);
164
+ boxPutPendingRef.current = {};
165
+ putHeldFreshRef.current = next.held.length > 0;
166
+ escArmedAtRef.current = 0;
167
+ }, [updateBuffer]);
168
+ const ownsEscape = useCallback(() => bufferRef.current.length > 0 || escOwnedRef.current, []);
169
+ useEffect(() => {
170
+ if (!boxRef)
171
+ return undefined;
172
+ boxRef.current = { putInBox, ownsEscape };
173
+ return () => { if (boxRef.current?.putInBox === putInBox)
174
+ boxRef.current = null; };
175
+ }, [boxRef, putInBox, ownsEscape]);
98
176
  // I1 — history recall, called by the TextInput at the first / last visual row.
99
- const historyUp = useCallback(() => {
100
- const next = Math.min(histIdx + 1, history.length - 1);
177
+ const historyUp = useCallback((currentValue) => {
178
+ // LT-15 — text was just put in the box and has not rendered yet: this ↑
179
+ // belongs to the same burst. Do nothing rather than overwrite it.
180
+ if (boxPutPendingRef.current !== null)
181
+ return;
182
+ // LT-15 Feature 2 — an EMPTY box takes back the last waiting message.
183
+ // A miss (nothing waits, no turn, the loop just read it) falls through.
184
+ // Fix round 1 (I1) — "empty" is the field's CURRENT text (its synchronous
185
+ // mirror), not this render's value: a key typed in the same burst counts.
186
+ const now = currentValue ?? value;
187
+ if (now === '' && histIdx === -1 && bufferRef.current.length === 0 && onWithdraw?.())
188
+ return;
189
+ const skip = skipHistRef.current;
190
+ const skipOne = histIdx === -1 && skip !== null && history[history.length - 1] === skip;
191
+ // Fix round 1 (M3) — the copy is the ONLY entry: nothing older to show.
192
+ // Leave the box (and the skip) as they are rather than show the copy.
193
+ if (skipOne && history.length === 1)
194
+ return;
195
+ skipHistRef.current = null;
196
+ const next = Math.min(histIdx + (skipOne ? 2 : 1), history.length - 1);
101
197
  setHistIdx(next);
102
198
  setValue(history[history.length - 1 - next] ?? '');
103
- }, [histIdx, history]);
199
+ }, [histIdx, history, value, onWithdraw]);
104
200
  const historyDown = useCallback(() => {
105
201
  // Not browsing history: ↓ on the last row leaves the draft alone.
106
202
  if (histIdx === -1)
@@ -144,6 +240,29 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
144
240
  }
145
241
  // Task 7 — Esc discards the multiline draft.
146
242
  if (key.escape && bufferRef.current.length > 0) {
243
+ escOwnedRef.current = true;
244
+ queueMicrotask(() => { escOwnedRef.current = false; });
245
+ // LT-15 final review I1 — returned text, not edited since: the first
246
+ // Esc only warns; the second within the window discards.
247
+ const returned = putHeldFreshRef.current;
248
+ if (returned) {
249
+ const now = Date.now();
250
+ if (now - escArmedAtRef.current > RETURNED_ESC_WINDOW_MS) {
251
+ escArmedAtRef.current = now;
252
+ onNotice?.(RETURNED_ESC_HINT);
253
+ return;
254
+ }
255
+ putHeldFreshRef.current = false;
256
+ escArmedAtRef.current = 0;
257
+ // Live check 2026-10-26 — the returned message's last line sits in
258
+ // the field, not in the held lines: clear it too, or the discard
259
+ // leaves part of the message behind. Typed drafts keep their line.
260
+ inputHandleRef.current?.setValueNow('');
261
+ setValue('');
262
+ valueNowRef.current = '';
263
+ }
264
+ // Its history copy is no longer in the box: ↑ may show it again.
265
+ skipHistRef.current = null;
147
266
  updateBuffer([]);
148
267
  // Chips that lived only in the discarded lines go with them; a chip
149
268
  // still in the input keeps its text.
@@ -157,6 +276,8 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
157
276
  // ↑/↓ belong to the TextInput now (visual rows first, then history).
158
277
  }, { isActive: !disabled });
159
278
  const handleSubmit = useCallback((line) => {
279
+ putHeldFreshRef.current = false; // LT-15 final review I1 — sent or continued: no longer "returned"
280
+ skipHistRef.current = null; // LT-15 I2.4 — the skip lasts until the next submit
160
281
  // Task 7 — trailing `\` continues the message (chips stay folded until the final submit).
161
282
  if (line.endsWith('\\')) {
162
283
  updateBuffer([...bufferRef.current, line.slice(0, -1)]);
@@ -200,7 +321,7 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
200
321
  onNotice?.('no image on the clipboard');
201
322
  return chip;
202
323
  }, [onNotice]);
203
- const borderStyle = shouldUseUnicodeBoxChars() ? 'round' : 'classic';
324
+ const borderStyle = cardBorderStyle();
204
325
  const borderColor = roleProps('frame').color;
205
326
  if (fuzzyMode) {
206
327
  const g = getGlyphs();
@@ -216,5 +337,5 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
216
337
  if (disabled) {
217
338
  return (_jsx(Box, { borderStyle: borderStyle, borderColor: borderColor, paddingX: 1, width: boxWidth, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: waitingForCard ? PROMPT : PROMPT_GLYPH }), waitingForCard ? _jsx(Text, { ...roleProps('dim'), children: "(answer above to continue)" }) : null] }) }));
218
339
  }
219
- return (_jsx(Box, { flexDirection: "column", borderStyle: borderStyle, borderColor: borderColor, paddingX: 1, width: boxWidth, children: _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, onPasteImage: handlePasteImage, onLargePaste: handleLargePaste, wrapWidth: wrapWidth, maxLines: Math.max(1, Math.min(INPUT_MAX_LINES, maxLines ?? INPUT_MAX_LINES)), heldLines: buffer, prompt: PROMPT, onHistoryUp: historyUp, onHistoryDown: historyDown }) }));
340
+ return (_jsx(Box, { flexDirection: "column", borderStyle: borderStyle, borderColor: borderColor, paddingX: 1, width: boxWidth, children: _jsx(TextInput, { value: value, onChange: handleChange, onSubmit: handleSubmit, onPasteLines: handlePasteLines, onPasteImage: handlePasteImage, onLargePaste: handleLargePaste, wrapWidth: wrapWidth, maxLines: Math.max(1, Math.min(INPUT_MAX_LINES, maxLines ?? INPUT_MAX_LINES)), heldLines: buffer, prompt: PROMPT, handleRef: inputHandleRef, onHistoryUp: historyUp, onHistoryDown: historyDown }) }));
220
341
  }
@@ -95,9 +95,43 @@ export function createPendingTurnCancel() {
95
95
  export function autoDispatchDecision(s, cmd) {
96
96
  if (s.done)
97
97
  return { kind: 'skip' };
98
- if (s.exitRequested || s.cancelPending) {
98
+ if (s.exitRequested || s.cancelPending || (s.stopped === true && s.chatFallback !== true)) {
99
99
  const what = queuedWorkLabel(cmd).replace(/^the /, '');
100
100
  return { kind: 'not-started', line: `${what.charAt(0).toUpperCase()}${what.slice(1)} not started · run it when ready: ${cmd}` };
101
101
  }
102
102
  return { kind: 'go' };
103
103
  }
104
+ export function leftoverDecision(s) {
105
+ if (s.waiting <= 0)
106
+ return 'none';
107
+ if (s.done || s.exitRequested || s.forceExit)
108
+ return 'report-unsent';
109
+ if (s.ctrlCStop || s.errorEnd)
110
+ return 'to-input';
111
+ if (s.dispatchScheduled || s.restoreActive)
112
+ return 'wait';
113
+ return 'run';
114
+ }
115
+ /** LT-15 Feature 4 — the one line at exit for messages that never ran. */
116
+ export function unsentLine(n) {
117
+ return `${n} waiting message${n === 1 ? '' : 's'} not sent`;
118
+ }
119
+ export function decideEscape(s) {
120
+ if (s.overlayOpen || s.bufferCount > 0 || s.fuzzyOpen)
121
+ return 'none';
122
+ if (s.alreadyStopped || s.restoreActive)
123
+ return 'none';
124
+ if (s.turnActive)
125
+ return 'brake';
126
+ if (s.busy && s.phase === 'post' && s.canChain === true)
127
+ return 'cancel-pending';
128
+ return 'none';
129
+ }
130
+ /** The one line at the Esc press (D2: the short form when nothing waits). */
131
+ export function brakeLine(waiting) {
132
+ return waiting ? 'Stopping at the next safe point · your message goes next' : 'Stopping at the next safe point';
133
+ }
134
+ /** The one dim line a braked turn prints in place of the turn-error block. */
135
+ export function brakeEndLine(waiting) {
136
+ return waiting ? 'Stopped · your work so far is kept' : 'Stopped · your work so far is kept · type continue to pick up';
137
+ }
@@ -2,9 +2,10 @@
2
2
  //
3
3
  // Pure layout decisions for the Ink live area (CLI look piece 2, spec §2).
4
4
  // Order, top to bottom: Body (<Static>) → checklist → card slot → status
5
- // strip → input → footer → palettes. Ink 5 clears and redraws the WHOLE
6
- // screen once the live region reaches the terminal height (ink.js
7
- // `outputHeight >= stdout.rows`), so the live area is capped at rows − 2.
5
+ // strip → queue row (LT-15) → input → footer → palettes. Ink 5 clears and
6
+ // redraws the WHOLE screen once the live region reaches the terminal height
7
+ // (ink.js `outputHeight >= stdout.rows`), so the live area is capped at
8
+ // rows − 2.
8
9
  /** Precedence: model-driven blocking cards first, then user-opened panels. */
9
10
  const PRECEDENCE = ['approval', 'confirm', 'ask', 'coaching', 'files', 'rewind', 'timeline', 'shortcuts'];
10
11
  /** The ONE card shown in the slot (spec §1.6: one card at a time), or null. */
@@ -57,10 +58,13 @@ export const PALETTE_ROWS = 13;
57
58
  * even one input line fits beside the full card.
58
59
  * The status row is always counted (conservative: it is hidden while a card
59
60
  * is open, so the real frame is one row shorter).
61
+ * LT-15 Feature 1 — `queueRows` is the "N waiting" row's height as shown
62
+ * (0 when it is hidden). It is fixed like the status row; absent means 0.
60
63
  */
61
64
  export function liveBudget(args) {
62
65
  const cap = Math.max(0, args.rows - 2);
63
- const fixed = STATUS_ROWS + INPUT_BORDER_ROWS + FOOTER_ROWS + Math.max(0, args.paletteRows);
66
+ const fixed = STATUS_ROWS + INPUT_BORDER_ROWS + FOOTER_ROWS + Math.max(0, args.paletteRows)
67
+ + Math.max(0, args.queueRows ?? 0);
64
68
  const wanted = Math.max(0, args.checklistRows);
65
69
  const card = Math.max(0, args.cardRows);
66
70
  const checklistRows = card > 0
@@ -70,6 +74,19 @@ export function liveBudget(args) {
70
74
  const inputMaxLines = Math.max(1, Math.min(Math.max(1, args.inputLines), INPUT_MAX_LINES, remaining));
71
75
  return { checklistRows, inputMaxLines, showDescriptions: remaining >= 1 };
72
76
  }
77
+ /**
78
+ * LT-15 Task 2 fix round 1 (review I-2): the rows the "N waiting" row may
79
+ * take so the live area stays ≤ rows − 2 — the cap less the status strip,
80
+ * the input border, the footer, an open palette, the checklist's one folded
81
+ * line (when there is a checklist) and one input line. Never below 1: the
82
+ * row then folds to one line (formatQueueRow). The row is hidden while a
83
+ * card is open, so no card term.
84
+ */
85
+ export function queueRowBudget(args) {
86
+ const cap = Math.max(0, args.rows - 2);
87
+ const fixed = STATUS_ROWS + INPUT_BORDER_ROWS + FOOTER_ROWS + Math.max(0, args.paletteRows);
88
+ return Math.max(1, cap - fixed - Math.min(Math.max(0, args.checklistRows), 1) - 1);
89
+ }
73
90
  /**
74
91
  * Fix round 1 (review I1): the rows a card may take so the whole live area
75
92
  * stays ≤ rows − 2 with one input line beside it — the cap less the status
@@ -0,0 +1,14 @@
1
+ import { EventEmitter } from 'node:events';
2
+ /**
3
+ * LT-15 Task 4 (owner D1, plan review I3/I5) — repl.tsx asks the mounted
4
+ * <App> to put every waiting message back into the input box: a turn end
5
+ * after Ctrl+C or an error, and a session switch. <App> owns the box
6
+ * (returnQueueToBox), repl.tsx owns the turn, so they talk through this
7
+ * singleton — the same pattern as rewindEmitter. In classic mode <App> is
8
+ * never mounted and nothing listens.
9
+ */
10
+ export const queueBoxEmitter = new EventEmitter();
11
+ /** True when a mounted <App> took the request (it may still find nothing to put). */
12
+ export function requestQueueToBox() {
13
+ return queueBoxEmitter.emit('return');
14
+ }
@@ -7,6 +7,7 @@ import { roleProps, selectedProps } from './role-props.js';
7
7
  import { getGlyphs } from '../renderer/glyphs.js';
8
8
  import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
9
9
  import { sanitizeControlChars } from '../renderer/sanitize.js';
10
+ import { cardBorderStyle } from './ascii-chars.js';
10
11
  /**
11
12
  * RewindPanel — UX Wave 3 / Task 3; a card-slot occupant since CLI look
12
13
  * piece 2 (§10). The Esc-Esc (and /rewind) card that turns "undo for your
@@ -120,7 +121,7 @@ export function RewindPanel({ candidates, onRestore, onClose, width = uiWidth()
120
121
  const below = Math.max(0, candidates.length - windowStop);
121
122
  const scrolls = windowStart > 0 || below > 0;
122
123
  const g = getGlyphs();
123
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 2, width: width, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: truncateToWidth('Rewind · undo a write from this session', inner) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [empty && _jsx(Text, { ...roleProps('dim'), children: "No restorable writes this session" }), !empty && windowStart > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2191 ", windowStart, " more"] }), !empty && visible.map((c, i) => {
124
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: cardBorderStyle(), borderColor: roleProps('frame').color, paddingX: 2, width: width, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: truncateToWidth('Rewind · undo a write from this session', inner) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [empty && _jsx(Text, { ...roleProps('dim'), children: "No restorable writes this session" }), !empty && windowStart > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2191 ", windowStart, " more"] }), !empty && visible.map((c, i) => {
124
125
  const idx = windowStart + i;
125
126
  const sel = idx === cursor;
126
127
  const row = truncateToWidth(`${sel ? `${g.cursor} ` : ' '}${rewindRowLabel(c)}`, inner);
@@ -8,6 +8,7 @@ import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
8
8
  import { toolLabel } from '../renderer/tool-labels.js';
9
9
  import { sanitizeControlChars } from '../renderer/sanitize.js';
10
10
  import { hhmm } from '../rewind/format.js';
11
+ import { cardBorderStyle } from './ascii-chars.js';
11
12
  // v1.1: add 'skipped' (approval rejection logs) and 'rolled_back' (snapshot restore).
12
13
  // Both need session-schema bump (schema_version 2) — deferred.
13
14
  const MUTATING_TOOLS = new Set([
@@ -64,7 +65,7 @@ export function SessionTimeline({ entries, onClose, width = uiWidth() }) {
64
65
  return;
65
66
  }
66
67
  });
67
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 2, width: width, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Write history ", _jsx(Text, { ...roleProps('dim'), children: maxOffset > 0 ? '· ↑↓ scroll · Esc to close' : '· Esc to close' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [writes.length === 0 && _jsx(Text, { ...roleProps('dim'), children: "No writes this session" }), start > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2191 ", start, " more"] }), visible.map((e, i) => {
68
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: cardBorderStyle(), borderColor: roleProps('frame').color, paddingX: 2, width: width, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Write history ", _jsx(Text, { ...roleProps('dim'), children: maxOffset > 0 ? '· ↑↓ scroll · Esc to close' : '· Esc to close' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [writes.length === 0 && _jsx(Text, { ...roleProps('dim'), children: "No writes this session" }), start > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2191 ", start, " more"] }), visible.map((e, i) => {
68
69
  const time = `${hhmm(e.timestamp)} `;
69
70
  const glyph = e.isError ? g.failed : g.done;
70
71
  const verb = toolLabel(e.toolName, {}).verb;
@@ -88,7 +88,7 @@ export function computeDisplayWindow(valueLength, cursorOffset, budget, cursorVi
88
88
  const end = Math.min(valueLength, start + budget);
89
89
  return { start, end };
90
90
  }
91
- export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, onPasteImage, wrapWidth, maxLines, heldLines, prompt, onHistoryUp, onHistoryDown, onLargePaste, }) {
91
+ export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, onPasteImage, wrapWidth, maxLines, heldLines, prompt, onHistoryUp, onHistoryDown, onLargePaste, handleRef, }) {
92
92
  const [cursorOffset, setCursorOffset] = useState((originalValue || '').length);
93
93
  // The paste-safety core: synchronous mirrors of value + cursor. The
94
94
  // input handler reads/writes THESE, never the (possibly stale) props.
@@ -102,6 +102,9 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
102
102
  // the repro as words landing out of order). An echo — even a stale
103
103
  // one — must never overwrite the mirror; only EXTERNAL pushes may.
104
104
  const pendingEchoes = useRef([]);
105
+ // LT-15 Task 4 — the value prop as of the last render (for getValueNow).
106
+ const propValueRef = useRef(originalValue);
107
+ propValueRef.current = originalValue;
105
108
  // Re-sync the mirrors on EXTERNAL value pushes only (parent setValue('')
106
109
  // after submit, palette prefill). Echoes of our own onChange — current
107
110
  // or stale — are recognised via pendingEchoes and skipped: the mirror
@@ -119,6 +122,32 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
119
122
  setCursorOffset(originalValue.length);
120
123
  }
121
124
  }, [originalValue]);
125
+ // LT-15 (fix round 1, C1) — the synchronous put. The value is recorded as
126
+ // our own pending echo: when the parent's matching `value` prop commits,
127
+ // the re-sync effect above skips it (it is not an external push), so a
128
+ // keystroke typed on top of it in the meantime is never rewound.
129
+ useEffect(() => {
130
+ if (!handleRef)
131
+ return undefined;
132
+ const handle = {
133
+ setValueNow: (next) => {
134
+ valueRef.current = next;
135
+ cursorRef.current = next.length;
136
+ // LT-15 Task 4 — added, not replaced: an older echo of our own typing
137
+ // that commits late must still be recognised, or it would rewind the put.
138
+ pendingEchoes.current.push(next);
139
+ setCursorOffset(next.length);
140
+ },
141
+ // While the field has typing the parent has not echoed back yet, its
142
+ // mirror is the truth (a key typed just before an outside put). Otherwise
143
+ // the last rendered prop is: right after Enter the parent has already
144
+ // cleared the box, but the mirror only hears it in a later effect.
145
+ getValueNow: () => (pendingEchoes.current.length > 0 ? valueRef.current : propValueRef.current) ?? '',
146
+ };
147
+ handleRef.current = handle;
148
+ return () => { if (handleRef.current === handle)
149
+ handleRef.current = null; };
150
+ }, [handleRef]);
122
151
  // Clamp rendered cursor when the value shrinks under it (vendored
123
152
  // semantic from ink-text-input's first effect).
124
153
  useEffect(() => {
@@ -179,7 +208,7 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
179
208
  if (w.cursorLine > 0)
180
209
  moveCursor(indexAt(cur, wrapWidth, w.cursorLine - 1, w.cursorCol));
181
210
  else
182
- onHistoryUp?.();
211
+ onHistoryUp?.(cur);
183
212
  }
184
213
  else if (w.cursorLine < w.lines.length - 1) {
185
214
  moveCursor(indexAt(cur, wrapWidth, w.cursorLine + 1, w.cursorCol));
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { cardBorderStyle } from '../ascii-chars.js';
3
4
  import { roleProps } from '../role-props.js';
4
5
  // The model names a colour; the bar takes the matching theme role, so it follows
5
6
  // NO_COLOR and the light palette. White / unknown → the terminal's own text colour.
@@ -13,7 +14,7 @@ function barProps(c) {
13
14
  export function BarChart({ data }) {
14
15
  const max = Math.max(...data.map((d) => d.value), 1);
15
16
  const barWidth = 30;
16
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 1, children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: "Distribution" }), data.map((item) => {
17
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: cardBorderStyle(), borderColor: roleProps('frame').color, paddingX: 1, children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: "Distribution" }), data.map((item) => {
17
18
  const filled = Math.round((item.value / max) * barWidth);
18
19
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
19
20
  return (_jsxs(Box, { children: [_jsxs(Text, { children: [item.label.padEnd(28), " "] }), _jsx(Text, { ...barProps(item.color), children: bar }), _jsxs(Text, { children: [" ", item.value] })] }, item.label));
@@ -192,7 +192,7 @@ export function CardFrame({ tag, header, footnote, width, children }) {
192
192
  const bottom = edgeSegments([box.bl, box.br], box.h, footnote, width);
193
193
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: top.lead }), _jsx(Text, { ...roleProps(TAG_ROLE[tag], { bold: true }), children: tagShown }), headerShown ? _jsx(Text, { ...roleProps('dim'), children: headerShown }) : null, _jsx(Text, { ...frame, children: top.tail })] }), _jsx(Box, { flexDirection: "column", width: width, paddingX: 1, borderStyle: inkBorder(box), borderTop: false, borderBottom: false, borderColor: frame.color, children: children }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: bottom.lead }), _jsx(Text, { ...roleProps('dim'), children: bottom.label }), _jsx(Text, { ...frame, children: bottom.tail })] })] }));
194
194
  }
195
- export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTurn }) {
195
+ export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTurn, onEscape }) {
196
196
  // Re-assert raw stdin on mount: an earlier inquirer prompt can leave Ink's
197
197
  // raw-mode bookkeeping stale, and the card would then get no keys.
198
198
  useReclaimRawStdin();
@@ -222,6 +222,7 @@ export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTur
222
222
  if (Date.now() - mountedAt.current < CONFIRM_INPUT_GUARD_MS)
223
223
  return;
224
224
  if (key.escape) {
225
+ onEscape?.();
225
226
  finish({ value: null, cancelled: true });
226
227
  return;
227
228
  }
@@ -1,8 +1,9 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { cardBorderStyle } from '../ascii-chars.js';
3
4
  import { roleProps } from '../role-props.js';
4
5
  export function DepGraph({ data }) {
5
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 1, children: [_jsxs(Text, { ...roleProps('sap', { bold: true }), children: ["Dependencies \u2014 ", data.root] }), data.children.map((c, i) => {
6
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: cardBorderStyle(), borderColor: roleProps('frame').color, paddingX: 1, children: [_jsxs(Text, { ...roleProps('sap', { bold: true }), children: ["Dependencies \u2014 ", data.root] }), data.children.map((c, i) => {
6
7
  const isLast = i === data.children.length - 1;
7
8
  const prefix = isLast ? '└─' : '├─';
8
9
  return (_jsxs(Box, { children: [_jsxs(Text, { ...roleProps('dim'), children: [prefix, " "] }), _jsx(Text, { ...roleProps('warn'), children: c.name }), _jsxs(Text, { ...roleProps('dim'), children: [" (", c.type, ") "] }), _jsx(Text, { dimColor: true, children: c.relation })] }, c.name));
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { cardBorderStyle } from '../ascii-chars.js';
3
4
  import { roleProps } from '../role-props.js';
4
5
  export function DiffViewer({ data }) {
5
6
  const lines = data.unified.split('\n');
6
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 1, children: [_jsx(Text, { ...roleProps('dim', { bold: true }), children: "Diff" }), lines.map((line, idx) => {
7
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: cardBorderStyle(), borderColor: roleProps('frame').color, paddingX: 1, children: [_jsx(Text, { ...roleProps('dim', { bold: true }), children: "Diff" }), lines.map((line, idx) => {
7
8
  const role = line.startsWith('+') ? 'ok' :
8
9
  line.startsWith('-') ? 'bad' :
9
10
  line.startsWith('@@') ? 'sap' : 'dim';
@@ -1,5 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { cardBorderStyle } from '../ascii-chars.js';
3
4
  import { roleProps } from '../role-props.js';
4
5
  /**
5
6
  * Ink-mode rendering of cspeach-widget:question. Uses a double-border box
@@ -8,5 +9,5 @@ import { roleProps } from '../role-props.js';
8
9
  * and types their answer as the next REPL prompt.
9
10
  */
10
11
  export function QuestionCard({ data }) {
11
- return (_jsxs(Box, { borderStyle: "double", borderColor: roleProps('sap').color, flexDirection: "column", padding: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["Question: ", data.id] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: data.question }) }), data.context ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Context: ", data.context] }) })) : null, (data.kind === 'choice' || data.kind === 'multi') && data.choices ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: data.kind === 'choice' ? 'Pick one:' : 'Pick any (comma-separate):' }), data.choices.map((c, i) => (_jsxs(Text, { children: ["[", i + 1, "] ", c.label] }, c.value)))] })) : null] }));
12
+ return (_jsxs(Box, { borderStyle: cardBorderStyle('double'), borderColor: roleProps('sap').color, flexDirection: "column", padding: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["Question: ", data.id] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: data.question }) }), data.context ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Context: ", data.context] }) })) : null, (data.kind === 'choice' || data.kind === 'multi') && data.choices ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: data.kind === 'choice' ? 'Pick one:' : 'Pick any (comma-separate):' }), data.choices.map((c, i) => (_jsxs(Text, { children: ["[", i + 1, "] ", c.label] }, c.value)))] })) : null] }));
12
13
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import { roleProps } from '../role-props.js';
4
- import { shouldUseUnicodeBoxChars } from '../../renderer/tty.js';
4
+ import { cardBorderStyle } from '../ascii-chars.js';
5
5
  import { truncateToWidth } from '../../renderer/ui-width.js';
6
6
  /** Spec §8 table, in order. */
7
7
  export const SHORTCUT_ROWS = [
@@ -21,5 +21,5 @@ export function ShortcutsSheet({ width, onClose }) {
21
21
  useInput((input, key) => { if (key.escape || (key.ctrl && input === 'c'))
22
22
  onClose(); });
23
23
  const inner = Math.max(8, width - 4);
24
- return (_jsxs(Box, { flexDirection: "column", borderStyle: shouldUseUnicodeBoxChars() ? 'round' : 'classic', borderColor: roleProps('frame').color, paddingX: 1, width: width, children: [_jsx(Text, { bold: true, children: "Shortcuts" }), SHORTCUT_ROWS.map(([key, action]) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap'), children: key.padEnd(KEY_COL) }), truncateToWidth(action, Math.max(1, inner - KEY_COL))] }, key))), _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: truncateToWidth(SHORTCUTS_NOTE, inner) }), _jsx(Text, { ...roleProps('dim'), children: "Esc close" })] }));
24
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: cardBorderStyle(), borderColor: roleProps('frame').color, paddingX: 1, width: width, children: [_jsx(Text, { bold: true, children: "Shortcuts" }), SHORTCUT_ROWS.map(([key, action]) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap'), children: key.padEnd(KEY_COL) }), truncateToWidth(action, Math.max(1, inner - KEY_COL))] }, key))), _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: truncateToWidth(SHORTCUTS_NOTE, inner) }), _jsx(Text, { ...roleProps('dim'), children: "Esc close" })] }));
25
25
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { cardBorderStyle } from '../ascii-chars.js';
3
4
  import { roleProps } from '../role-props.js';
4
5
  export function StackFrames({ data }) {
5
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('bad').color, paddingX: 1, children: [_jsx(Text, { ...roleProps('bad', { bold: true }), children: "Stack Frames" }), data.map((f) => (_jsxs(Box, { flexDirection: "column", marginTop: f.frame === 0 ? 0 : 1, children: [_jsx(Box, { children: _jsxs(Text, { ...roleProps(f.type === 'error' ? 'bad' : 'dim'), children: ['#' + String(f.frame).padStart(2, '0'), " ", f.program, ":", f.line] }) }), _jsx(Box, { marginLeft: 5, children: _jsx(Text, { ...(f.type === 'error' ? {} : roleProps('dim')), dimColor: f.type === 'context', children: f.code }) })] }, f.frame)))] }));
6
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: cardBorderStyle(), borderColor: roleProps('bad').color, paddingX: 1, children: [_jsx(Text, { ...roleProps('bad', { bold: true }), children: "Stack Frames" }), data.map((f) => (_jsxs(Box, { flexDirection: "column", marginTop: f.frame === 0 ? 0 : 1, children: [_jsx(Box, { children: _jsxs(Text, { ...roleProps(f.type === 'error' ? 'bad' : 'dim'), children: ['#' + String(f.frame).padStart(2, '0'), " ", f.program, ":", f.line] }) }), _jsx(Box, { marginLeft: 5, children: _jsx(Text, { ...(f.type === 'error' ? {} : roleProps('dim')), dimColor: f.type === 'context', children: f.code }) })] }, f.frame)))] }));
6
7
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 2026-10-23 read retry — one more try, after a short pause, when a GET read
3
+ * could not connect: a connect timeout, a DNS error, or a reset before any
4
+ * response. A brief DNS stall on a laptop (seen in the eval, likely Docker
5
+ * changing adapters) clears on the second try.
6
+ *
7
+ * GET only. Never wrap /v1/messages or any POST/write with this: a retried
8
+ * write could run twice. It does not touch the global dispatcher, agents or
9
+ * DNS order, so NODE_USE_ENV_PROXY keeps working on corporate VDIs.
10
+ *
11
+ * A copy of this rule lives in cspeach-proxy/src/skills/manifest.ts
12
+ * (fetchManifestResponse) — separate package, no shared code. Change both.
13
+ */
14
+ /** The error codes worth one more try. Read from `.code` only, never the message. */
15
+ const RETRYABLE_CODES = new Set(['UND_ERR_CONNECT_TIMEOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET']);
16
+ export const CONNECT_RETRY_DELAY_MS = 500;
17
+ /** True when `e` (or its cause chain — undici wraps it as TypeError('fetch failed', { cause })) carries a retryable code. */
18
+ export function isConnectRetryable(e) {
19
+ let cur = e;
20
+ for (let depth = 0; depth < 4 && cur && typeof cur === 'object'; depth++) {
21
+ const code = cur.code;
22
+ if (typeof code === 'string' && RETRYABLE_CODES.has(code))
23
+ return true;
24
+ cur = cur.cause;
25
+ }
26
+ return false;
27
+ }
28
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
+ /**
30
+ * fetch with one retry on a connect failure, for GET only. `makeInit` is
31
+ * called once per attempt so each try gets a fresh timeout signal; when that
32
+ * per-try timer fires (TimeoutError) it counts as a connect failure. A caller
33
+ * abort never does. An HTTP
34
+ * error status is a response, returned as is. After the one retry fails, the
35
+ * ORIGINAL error is thrown, so callers' messages stay the same.
36
+ */
37
+ export async function fetchWithConnectRetry(url, makeInit, opts = {}) {
38
+ const doFetch = opts.fetchImpl ?? fetch;
39
+ const first = makeInit();
40
+ try {
41
+ return await doFetch(url, first);
42
+ }
43
+ catch (e) {
44
+ const method = (first.method ?? 'GET').toUpperCase();
45
+ if (method !== 'GET')
46
+ throw e;
47
+ const reasonName = first.signal?.aborted ? first.signal.reason?.name : undefined;
48
+ // Our own per-try timer fired (a stalled connect/DNS, in effect a connect
49
+ // timeout; it races undici's own 10 s connect timeout). makeInit hands the
50
+ // retry a fresh timer.
51
+ const ownTimerFired = reasonName === 'TimeoutError' && e?.name === 'TimeoutError';
52
+ if (!isConnectRetryable(e) && !ownTimerFired)
53
+ throw e;
54
+ // The caller gave up (user cancel / session end): never a second try.
55
+ if (first.signal?.aborted && reasonName !== 'TimeoutError')
56
+ throw e;
57
+ await (opts.sleep ?? defaultSleep)(CONNECT_RETRY_DELAY_MS);
58
+ try {
59
+ return await doFetch(url, makeInit());
60
+ }
61
+ catch {
62
+ throw e;
63
+ }
64
+ }
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.50",
3
+ "version": "1.1.52",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",