@cspeach/cli 0.7.0 → 0.8.0

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.
@@ -74,26 +74,36 @@ function findBalancedJson(text, from) {
74
74
  * Returns the offset where the block begins in `text`.
75
75
  */
76
76
  function findBlockStart(text, jsonStart) {
77
- let pos = jsonStart;
78
77
  // Walk backward over preceding lines that are empty, the sentinel, or
79
78
  // a fence-open marker. Stop on any line that has other content.
80
- while (pos > 0) {
81
- const prevNewline = text.lastIndexOf('\n', pos - 1);
79
+ //
80
+ // 2026-06-06 — rewritten to guarantee forward progress. The previous
81
+ // version set `pos = lineStart` and looped; when the `{` sat at the
82
+ // start of a line (lineStart === pos — the CANONICAL widget shape),
83
+ // pos never moved and the loop spun forever, pegging a core. The scan
84
+ // position now steps over the preceding newline each iteration
85
+ // (strictly decreasing), and the accepted block start is tracked
86
+ // separately so the return value is still a line-start offset.
87
+ let blockStart = jsonStart;
88
+ let scanEnd = jsonStart; // exclusive end of the line-chunk under examination
89
+ while (scanEnd > 0) {
90
+ const prevNewline = text.lastIndexOf('\n', scanEnd - 1);
82
91
  const lineStart = prevNewline === -1 ? 0 : prevNewline + 1;
83
- const line = text.slice(lineStart, pos).trim();
92
+ const line = text.slice(lineStart, scanEnd).trim();
84
93
  if (line === '' ||
85
94
  line === SENTINEL ||
86
95
  /^```(?:cspeach-widget:\S+)?$/.test(line) ||
87
96
  /^````(?:cspeach-widget:\S+)?$/.test(line)) {
88
- pos = lineStart;
97
+ blockStart = lineStart;
89
98
  if (prevNewline === -1)
90
99
  break;
100
+ scanEnd = prevNewline; // step over the newline — strictly decreasing
91
101
  }
92
102
  else {
93
103
  break;
94
104
  }
95
105
  }
96
- return pos;
106
+ return blockStart;
97
107
  }
98
108
  /**
99
109
  * Walk forward from `jsonEnd` (one past the closing `}`) to include a
package/dist/repl.js CHANGED
@@ -715,6 +715,12 @@ export async function runRepl(opts) {
715
715
  last_skill: session.last_skill ?? null,
716
716
  last_object: extractedObject ?? session.last_object ?? null,
717
717
  });
718
+ if (c.offline) {
719
+ // Remote classify failed twice — local-rules fallback fired.
720
+ // Tell the user so they know the picker preselection came
721
+ // from regex, not the LLM, and surface the real cause.
722
+ chunkEmitter.emit('chunk', chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]\n`));
723
+ }
718
724
  const decision = decideRouting(c);
719
725
  // 2026-05-17 — pass the just-used skill so the picker can
720
726
  // default cursor to it (one-Enter "continue" UX for follow-up
@@ -868,11 +874,19 @@ export async function runRepl(opts) {
868
874
  writeMode: cfg.write_mode,
869
875
  onInterrupt,
870
876
  }), {
871
- // Critical: without this Ink calls process.exit() on Ctrl+C and
872
- // kills the CLI before our onInterrupt handler can decide what
873
- // to do. We want to make that decision ourselves (abort turn
874
- // vs exit at idle).
877
+ // exitOnCtrlC: false — Ink would otherwise call process.exit()
878
+ // on Ctrl+C before onInterrupt can decide whether to abort the
879
+ // in-flight turn or exit cleanly.
875
880
  exitOnCtrlC: false,
881
+ // W2.1 (2026-05-18) — patchConsole intercepts console.log /
882
+ // console.error / console.warn from inside ANY code that runs
883
+ // while Ink owns the frame, routes them above the Ink render
884
+ // region as Static-committed scrollback rather than corrupting
885
+ // the live frame. CC uses this (12 hits in v0.2.9 source).
886
+ // Removes a whole class of "console.log ate my frame" bugs and
887
+ // lets us delete the 6 manual chunkEmitter routings we added
888
+ // in commit 349ff75.
889
+ patchConsole: true,
876
890
  });
877
891
  await donePromise;
878
892
  unmount();
@@ -1536,6 +1550,9 @@ export async function runRepl(opts) {
1536
1550
  last_skill: session.last_skill ?? null,
1537
1551
  last_object: extractedObject ?? session.last_object ?? null,
1538
1552
  });
1553
+ if (c.offline) {
1554
+ console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
1555
+ }
1539
1556
  const decision = decideRouting(c);
1540
1557
  // 2026-05-17 — pass the just-used skill so the picker can
1541
1558
  // default cursor to it. session.skill = immediately-previous
@@ -1,19 +1,85 @@
1
1
  import { loadConfig } from '../config/loader.js';
2
2
  import { getStore } from '../auth/api-key.js';
3
+ import { classify as classifyLocal } from '../classifier/client.js';
4
+ // 8s — sized to cover the observed Fly cold-start (~3.2s on the 2026-05-20
5
+ // probe) plus headroom for a slow network. Tighter values risk timing out
6
+ // legitimate cold-start hits and burning the retry budget on the same.
7
+ const FETCH_TIMEOUT_MS = 8000;
8
+ // 250ms — long enough for a half-open TCP/keep-alive socket to be fully
9
+ // torn down before we try again, short enough that the user doesn't feel
10
+ // the retry as a hang.
11
+ const RETRY_DELAY_MS = 250;
12
+ /**
13
+ * Extract the real failure cause from a Node fetch / undici error. Node's
14
+ * native fetch surfaces `TypeError: fetch failed` and hides the actual
15
+ * layer on `err.cause` (UND_ERR_*, ENOTFOUND, ECONNRESET, ETIMEDOUT, TLS
16
+ * handshake aborts, etc.). This lets future failure messages name the
17
+ * actual layer so we can tell DNS vs TLS vs socket-reset vs Fly cold start.
18
+ */
19
+ function extractCause(err) {
20
+ const e = err;
21
+ if (e.cause) {
22
+ return `${e.cause.code ?? e.cause.errno ?? 'unknown'}${e.cause.message ? `: ${e.cause.message}` : ''}`;
23
+ }
24
+ return e.message;
25
+ }
26
+ async function tryFetchOnce(proxyUrl, key, prompt, context) {
27
+ const ctrl = new AbortController();
28
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
29
+ try {
30
+ return await fetch(`${proxyUrl}/v1/classify`, {
31
+ method: 'POST',
32
+ headers: {
33
+ 'Authorization': `Bearer ${key}`,
34
+ 'Content-Type': 'application/json',
35
+ },
36
+ body: JSON.stringify({ prompt, context }),
37
+ signal: ctrl.signal,
38
+ });
39
+ }
40
+ finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
3
44
  export async function classifyPrompt(prompt, context) {
4
45
  const cfg = await loadConfig();
5
46
  const store = await getStore();
6
47
  const key = await store.get();
7
48
  if (!key)
8
49
  throw new Error('no_api_key');
9
- const r = await fetch(`${cfg.proxy_url}/v1/classify`, {
10
- method: 'POST',
11
- headers: {
12
- 'Authorization': `Bearer ${key}`,
13
- 'Content-Type': 'application/json',
14
- },
15
- body: JSON.stringify({ prompt, context }),
16
- });
50
+ let r;
51
+ let lastErr;
52
+ // Two attempts total: the second masks the common case of a stale
53
+ // keep-alive socket reused against a just-cold-suspended Fly machine
54
+ // (2026-05-20 probe showed first-hit cold = 3.2s, warm = 460ms — the
55
+ // half-open socket fails fast at the transport layer, then a fresh
56
+ // connection succeeds). HTTP-level failures (non-2xx) do NOT retry —
57
+ // they bubble through as before.
58
+ for (let attempt = 0; attempt < 2; attempt++) {
59
+ try {
60
+ r = await tryFetchOnce(cfg.proxy_url, key, prompt, context);
61
+ break;
62
+ }
63
+ catch (err) {
64
+ lastErr = err;
65
+ if (attempt === 0)
66
+ await new Promise((res) => setTimeout(res, RETRY_DELAY_MS));
67
+ }
68
+ }
69
+ if (!r) {
70
+ // Both attempts failed at the transport layer. Degrade to the bundled
71
+ // local rules so the user's turn is NOT dropped — they still get a
72
+ // coaching picker (confidence 0.3 < 0.60 = picker decision), and the
73
+ // REPL surfaces an offline hint with the real cause.
74
+ const skill = classifyLocal(prompt) ?? 'abap-radar';
75
+ return {
76
+ skill,
77
+ confidence: 0.3,
78
+ alternates: [],
79
+ offline: true,
80
+ transport_error: lastErr ? extractCause(lastErr) : 'unknown',
81
+ };
82
+ }
17
83
  if (!r.ok)
18
84
  throw new Error(`classifier HTTP ${r.status}`);
19
85
  return (await r.json());
@@ -124,6 +124,13 @@ export async function agentRunHandler(args, ctx) {
124
124
  // Increment depth env var BEFORE the child runs; restore after.
125
125
  const priorDepth = process.env.CSPEACH_AGENT_DEPTH;
126
126
  process.env.CSPEACH_AGENT_DEPTH = String(depth + 1);
127
+ // W2.5 — when read_only is set, build a tool filter that drops every
128
+ // mutating tool from the subagent's tool list. Saves tokens on the
129
+ // sub-agent invocation (smaller tools array = less input cost) AND
130
+ // hard-prevents accidental writes from the subagent.
131
+ const toolFilter = args.read_only
132
+ ? (t) => !t.isMutating
133
+ : undefined;
127
134
  try {
128
135
  await runTurn({
129
136
  provider: ctx.provider,
@@ -132,6 +139,7 @@ export async function agentRunHandler(args, ctx) {
132
139
  ctx: childCtx,
133
140
  chunkEmitter: nullSinkEmitter,
134
141
  maxTokensOverride,
142
+ toolFilter,
135
143
  });
136
144
  }
137
145
  catch (err) {
@@ -179,6 +187,10 @@ registerTool({
179
187
  type: 'number',
180
188
  description: 'Optional per-call cap. Default 100000, hard cap 200000.',
181
189
  },
190
+ read_only: {
191
+ type: 'boolean',
192
+ description: 'When true, the subagent only sees read-only tools (no sap_set_source, no file-write, no shell_exec, etc.). Use this for research / analysis subagents that should not make changes — e.g. spawning a subagent to read code, run greps, or summarise findings. Smaller tools array = lower per-call cost; also a hard guard against accidental writes.',
193
+ },
182
194
  },
183
195
  required: ['skill', 'task'],
184
196
  },
package/dist/ui/app.js CHANGED
@@ -103,7 +103,15 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
103
103
  } })), askQuestionPending && (_jsx(AskQuestionModal, { params: askQuestionPending, onResolve: (result) => {
104
104
  askQuestionPending.resolve(result);
105
105
  setAskQuestionPending(null);
106
- } })), _jsx(Footer, { onSubmit: onSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, paletteResult: paletteResult, onValueChange: setFooterValue }), showPalette && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { onSelect: (s) => {
106
+ } })), _jsx(Footer, { onSubmit: onSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, paletteResult: paletteResult, onValueChange: setFooterValue,
107
+ // 2026-05-17 — disable Footer input while any modal owns
108
+ // input, so keystrokes don't double-fire (modal answer +
109
+ // spurious Footer submission with same text).
110
+ disabled: !!coachingPending
111
+ || !!askQuestionPending
112
+ || !!pendingApproval
113
+ || showPalette
114
+ || showTimeline }), showPalette && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { onSelect: (s) => {
107
115
  setPaletteResult(s);
108
116
  setShowPalette(false);
109
117
  }, onCancel: () => setShowPalette(false) }) })), errorStack.map((e) => (_jsx(Box, { children: _jsx(RichErrorBox, { code: e.code, message: e.body, hint: e.hint, severity: "error" }) }, e.id))), showTimeline && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(SessionTimeline, { entries: timelineEntries, onClose: () => setShowTimeline(false) }) })), pendingApproval && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
package/dist/ui/body.js CHANGED
@@ -3,6 +3,56 @@ import React, { useReducer, useEffect } from 'react';
3
3
  import { Text, Static } from 'ink';
4
4
  import { WIDGET_COMPONENTS } from './widgets/component-registry.js';
5
5
  import { WIDGET_CONTROL } from './widget-control.js';
6
+ /**
7
+ * Frame interval for batching streaming chunks before dispatching to the
8
+ * reducer (W2.2 — 2026-05-18). Ink already throttles its stdout writes to
9
+ * 32 ms via `es-toolkit/compat throttle` at the renderer level — but the
10
+ * React **reducer** still runs once per chunk in our useReducer setup.
11
+ * For a long code-gen turn (5000+ chunks of 1-3 bytes each), the cumulative
12
+ * regex-split + string-concat cost in `bodyReducer` becomes the hotspot.
13
+ *
14
+ * Batching here gives one reducer call per ~16 ms regardless of chunk rate,
15
+ * preserving the perceived "real-time stream" feel (still 60Hz) while
16
+ * collapsing reducer work by 1-2 orders of magnitude on dense streams.
17
+ *
18
+ * `requestAnimationFrame` is unavailable in Node, so we use `setTimeout(16)` —
19
+ * same effective cadence, cross-platform safe. We re-arm the timer only when
20
+ * a chunk arrives and no flush is pending, so an idle stream pays nothing.
21
+ */
22
+ export const RENDER_BATCH_MS = 16;
23
+ export function createChunkBatcher(onFlush, intervalMs = RENDER_BATCH_MS) {
24
+ let buffered = '';
25
+ let timer = null;
26
+ const fire = () => {
27
+ timer = null;
28
+ if (buffered.length === 0)
29
+ return;
30
+ const out = buffered;
31
+ buffered = '';
32
+ onFlush(out);
33
+ };
34
+ return {
35
+ push(chunk) {
36
+ if (chunk.length === 0)
37
+ return;
38
+ buffered += chunk;
39
+ if (timer === null) {
40
+ timer = setTimeout(fire, intervalMs);
41
+ }
42
+ },
43
+ dispose() {
44
+ if (timer !== null) {
45
+ clearTimeout(timer);
46
+ timer = null;
47
+ }
48
+ if (buffered.length > 0) {
49
+ const out = buffered;
50
+ buffered = '';
51
+ onFlush(out);
52
+ }
53
+ },
54
+ };
55
+ }
6
56
  // WIDGET_CONTROL is the longer sentinel (`\x00CSPEACH-WIDGET-V3\x00`).
7
57
  // RegExp escaping: `\x00` works in JS regex source directly.
8
58
  const WIDGET_RE = new RegExp(WIDGET_CONTROL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
@@ -105,9 +155,18 @@ function renderNode(n) {
105
155
  export const Body = React.memo(function Body({ chunkEmitter }) {
106
156
  const [state, dispatch] = useReducer(bodyReducer, INITIAL_BODY_STATE);
107
157
  useEffect(() => {
108
- const handler = (chunk) => dispatch({ chunk });
158
+ // W2.2 — chunk batching. createChunkBatcher buffers incoming chunks
159
+ // and dispatches at most once per RENDER_BATCH_MS window. Idle stream
160
+ // pays nothing; dense stream (e.g. 5000 chunks during code-gen) sees
161
+ // ~60 reducer calls instead of 5000. dispose() flushes pending text
162
+ // synchronously on unmount so end-of-stream output isn't dropped.
163
+ const batcher = createChunkBatcher((buffered) => dispatch({ chunk: buffered }));
164
+ const handler = (chunk) => batcher.push(chunk);
109
165
  chunkEmitter.on('chunk', handler);
110
- return () => { chunkEmitter.off('chunk', handler); };
166
+ return () => {
167
+ chunkEmitter.off('chunk', handler);
168
+ batcher.dispose();
169
+ };
111
170
  }, [chunkEmitter]);
112
171
  // <Static> takes the nodes array; on each render it appends ONLY new
113
172
  // items since the last render. Already-rendered items are not
package/dist/ui/footer.js CHANGED
@@ -24,7 +24,7 @@ function appendHistory(line, history) {
24
24
  history.push(line);
25
25
  }
26
26
  const PEACH = '#FFCBA4';
27
- export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, paletteResult }) {
27
+ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, paletteResult, disabled }) {
28
28
  const [value, setValue] = useState('');
29
29
  const [history] = useState(loadHistory);
30
30
  const [histIdx, setHistIdx] = useState(-1);
@@ -99,10 +99,13 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
99
99
  if (fuzzyMode) {
100
100
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search..." }), fuzzyResults.map((r, i) => _jsx(Text, { dimColor: i > 0, children: r }, i)), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
101
101
  }
102
- return (
103
- // Phase D6 (2026-05-17) — borderless input. The border had pushed
104
- // the terminal cursor one line below the input (the "booger"); now
105
- // both the terminal cursor AND TextInput's block character land at
106
- // the same position, appearing as a single blinking cursor.
107
- _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit })] }));
102
+ // 2026-05-17 — when a modal owns input (coaching picker / ask_question),
103
+ // render a dim placeholder INSTEAD of the live TextInput. Otherwise
104
+ // keystrokes go to BOTH the modal AND this TextInput, and the queued
105
+ // Footer submission fires after the modal resolves → spurious picker
106
+ // for what the user thought was their modal answer.
107
+ if (disabled) {
108
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
109
+ }
110
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit })] }));
108
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -40,6 +40,7 @@
40
40
  "adt:smoke": "tsx scripts/adt-smoke.ts",
41
41
  "smoke:all": "pnpm zcspeach:smoke && pnpm adt:smoke",
42
42
  "zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts",
43
+ "bench:models": "tsx scripts/bench-models.ts",
43
44
  "prepare": "husky"
44
45
  },
45
46
  "engines": {