@cspeach/cli 1.1.19 → 1.1.20

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 (57) hide show
  1. package/dist/agent/anthropic-provider.js +30 -10
  2. package/dist/agent/cache-keepalive.js +162 -0
  3. package/dist/agent/cold-prune.js +116 -0
  4. package/dist/agent/loop.js +675 -148
  5. package/dist/agent/provider-shape.js +263 -0
  6. package/dist/agent/providers/ai-hub-provider.js +17 -2
  7. package/dist/agent/providers/byok-provider.js +33 -3
  8. package/dist/agent/providers/local-provider.js +8 -1
  9. package/dist/agent/repair-partial.js +66 -5
  10. package/dist/agent/summarise-via-provider.js +6 -1
  11. package/dist/agent/system-prompt.js +38 -0
  12. package/dist/agent/tool-dispatch.js +8 -0
  13. package/dist/agent/tool-loading-pin.js +100 -0
  14. package/dist/cli.js +11 -0
  15. package/dist/commands/auto-compact.js +33 -16
  16. package/dist/commands/compact.js +37 -2
  17. package/dist/commands/config-set.js +10 -1
  18. package/dist/commands/config-show.js +11 -0
  19. package/dist/commands/cost.js +14 -2
  20. package/dist/commands/plan-audit.js +1 -0
  21. package/dist/config/loader.js +55 -2
  22. package/dist/cost/cost-log.js +62 -2
  23. package/dist/cost/pricing.js +6 -2
  24. package/dist/lib/spill-labels.js +13 -0
  25. package/dist/models/resolve.js +93 -2
  26. package/dist/models/server-config.js +158 -3
  27. package/dist/one-shot.js +15 -5
  28. package/dist/projects/image-attachments.js +15 -2
  29. package/dist/renderer/footer-line.js +6 -2
  30. package/dist/renderer/startup-lines.js +5 -3
  31. package/dist/renderer/tool-labels.js +33 -2
  32. package/dist/renderer/ui-width.js +13 -0
  33. package/dist/repl/current-transport.js +13 -0
  34. package/dist/repl/post-turn-status.js +8 -1
  35. package/dist/repl.js +71 -10
  36. package/dist/session/repin-model.js +18 -0
  37. package/dist/session/store.js +16 -2
  38. package/dist/skills/bundled-skills.js +1 -1
  39. package/dist/skills/preamble.js +75 -0
  40. package/dist/skills/source-manifest.js +11 -1
  41. package/dist/tools/filesystem/file-read.js +11 -1
  42. package/dist/tools/result-spill.js +238 -0
  43. package/dist/tools/sap-read.js +58 -14
  44. package/dist/tools/shell/shell_exec.js +9 -0
  45. package/dist/tools/subagent/adt-serial.js +33 -0
  46. package/dist/tools/subagent/agent_run.js +2 -0
  47. package/dist/tools/subagent/read_agent.js +178 -0
  48. package/dist/tools/subagent/reader-prompt.js +48 -0
  49. package/dist/tools/todo.js +3 -1
  50. package/dist/tools/tool-loading.js +255 -0
  51. package/dist/tools/tool-output-read.js +117 -0
  52. package/dist/tools/transport.js +6 -1
  53. package/dist/ui/footer.js +5 -5
  54. package/dist/ui/sap-state-store.js +1 -1
  55. package/dist/ui/turn-status-emitter.js +37 -0
  56. package/dist/ui/turn-status.js +1 -1
  57. package/package.json +2 -1
@@ -0,0 +1,255 @@
1
+ import { toAnthropicTools } from './index.js';
2
+ /** The server-side tool search tool (probe P6: this exact name). */
3
+ export const TOOL_SEARCH_TOOL = Object.freeze({
4
+ type: 'tool_search_tool_regex_20251119',
5
+ name: 'tool_search_tool_regex',
6
+ });
7
+ /** Beta that allows `role: 'system'` tool_addition messages (and rides with deferred tools). */
8
+ export const TOOL_CHANGES_BETA = 'mid-conversation-tool-changes-2026-07-01';
9
+ /**
10
+ * Spec D11 — always loaded. Names missing from the list a build is given
11
+ * (flag-gated `agent_run` / file tools when their flag is off; every SAP tool
12
+ * in standalone) are skipped, never an error (ruling F25). `read_agent`
13
+ * (Task 18) is core because the preamble's heavy-reading rule names it.
14
+ */
15
+ export const CORE_TOOLS = Object.freeze([
16
+ 'ask_question',
17
+ 'todo_set',
18
+ 'request_approval',
19
+ 'dispatch_skill',
20
+ 'agent_run',
21
+ 'read_agent',
22
+ 'file_read',
23
+ 'tool_output_read',
24
+ 'file_write',
25
+ 'shell_exec',
26
+ 'sap_get_source',
27
+ 'sap_search_object',
28
+ 'sap_object_structure',
29
+ 'sap_sql_query',
30
+ 'sap_syntax_check',
31
+ 'sap_set_source',
32
+ 'sap_update_method',
33
+ 'sap_activate',
34
+ 'sap_inactive_objects',
35
+ 'sap_create_object',
36
+ ]);
37
+ const FIORI_FAMILY = [
38
+ 'fiori_apply', 'fiori_deploy_config', 'fiori_draft_smoke', 'fiori_fe_extend',
39
+ 'fiori_list', 'fiori_metadata_audit', 'fiori_preview', 'fiori_render_smoke',
40
+ 'fiori_sample_get', 'fiori_sample_search', 'fiori_scaffold', 'fiori_scaffold_fe',
41
+ ];
42
+ const TRANSPORT_FAMILY = [
43
+ 'sap_transport_create', 'sap_transport_for_object', 'sap_transport_list', 'sap_transport_release',
44
+ ];
45
+ /**
46
+ * Per-skill starter tools: the non-core tools a skill's body names as its
47
+ * working set (counted from `.claude/skills/<skill>/SKILL.md`). A tool left
48
+ * out is still reachable through tool search — a starter only saves the
49
+ * search round. Skills not listed start with the core set alone.
50
+ */
51
+ export const SKILL_STARTERS = Object.freeze({
52
+ 'abap-fiori-build': FIORI_FAMILY,
53
+ 'abap-transport': TRANSPORT_FAMILY,
54
+ 'abap-transport-analysis': [...TRANSPORT_FAMILY, 'sap_atc_run', 'sap_usage_references'],
55
+ 'abap-cap': ['fiori_scaffold_fe', 'fiori_render_smoke', 'fiori_draft_smoke'],
56
+ 'abap-atc-fix': ['sap_atc_run'],
57
+ 'abap-cca': ['sap_atc_run', 'sap_usage_references', 'sap_api_state'],
58
+ 'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references'],
59
+ 'abap-data-model': ['sap_create_domain', 'sap_create_data_element'],
60
+ 'abap-document': ['sap_usage_references'],
61
+ 'abap-handover': ['sap_usage_references'],
62
+ 'abap-impact': ['sap_usage_references', 'sap_atc_run'],
63
+ 'abap-performance': ['sap_usage_references', 'sap_atc_run'],
64
+ 'abap-migrate': ['sap_usage_references'],
65
+ 'abap-dump': ['sap_short_dump', 'sap_snapshot_take'],
66
+ 'abap-incident': ['sap_short_dump', 'sap_snapshot_take', 'sap_atc_run', 'sap_run_unit_test', 'sap_api_state', 'sap_diff'],
67
+ 'abap-eml': ['sap_run_unit_test'],
68
+ 'abap-test': ['sap_run_unit_test', 'sap_set_class_include'],
69
+ 'abap-extend-model': ['extend_model_insert', 'sap_snapshot_take', 'sap_service_binding_publish', 'sap_set_class_include'],
70
+ 'abap-rap': ['sap_set_class_include', 'sap_service_binding_publish', 'sap_class_includes'],
71
+ 'abap-modernize': ['sap_api_state', 'sap_snapshot_take', 'sap_service_binding_publish', 'sap_atc_run', 'sap_transport_create'],
72
+ 'abap-segw': ['sap_snapshot_take', 'sap_odata_test'],
73
+ 'abap-upgrade-fix': ['sap_snapshot_take', 'glob'],
74
+ 'abap-upgrade-scan': ['sap_atc_run'],
75
+ 'abap-upgrade-verify': ['sap_atc_run'],
76
+ });
77
+ /** The starter tools for a skill; unknown skill (or none) ⇒ []. */
78
+ export function startersForSkill(skill) {
79
+ if (!skill)
80
+ return [];
81
+ const s = SKILL_STARTERS[skill];
82
+ return s ? [...s] : [];
83
+ }
84
+ /**
85
+ * Final review C1 (controller ruling, 2026-09-28) — ALLOW-list of the model
86
+ * families the deferred shapes (defer_loading entries, the tool search tool,
87
+ * `role: 'system'` tool_addition messages) were probe-proven on (P6, D1, D3:
88
+ * claude-opus-5 and claude-opus-5-5). Same matching as the other allow-lists
89
+ * in agent/provider-shape.ts (lowercased, trailing -YYYYMMDD stripped). Every
90
+ * other model — claude-opus-4-8 (the session default until the global flip),
91
+ * the claude-sonnet-4-6 plan/audit tier, haiku — gets the static list. The
92
+ * loop decides by the model actually sent on each call.
93
+ */
94
+ export const DEFERRED_TOOL_MODELS = new Set([
95
+ 'claude-opus-5-5', 'claude-opus-5',
96
+ ]);
97
+ export function modelAcceptsDeferredTools(model) {
98
+ if (typeof model !== 'string')
99
+ return false;
100
+ return DEFERRED_TOOL_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
101
+ }
102
+ /**
103
+ * Mode precedence (ruling F13): AI-hub and local are always 'static' (their
104
+ * wire has no deferred loading). Otherwise env `CSPEACH_TOOL_LOADING`
105
+ * (`deferred` | `static`) wins; then managed uses the served value (default
106
+ * 'static': only a proxy that serves tool_loading takes the new shapes);
107
+ * BYOK uses the served value when it fetched one, else 'static'.
108
+ */
109
+ export function resolveToolLoading(i) {
110
+ if (i.providerMode === 'ai-hub' || i.providerMode === 'local')
111
+ return 'static';
112
+ const raw = i.env.CSPEACH_TOOL_LOADING?.trim().toLowerCase();
113
+ if (raw === 'deferred' || raw === 'static')
114
+ return raw;
115
+ // Task 21 fix round (controller ruling): an old proxy serves no tool_loading
116
+ // and adds none of the deferred-tool betas, so 'deferred' only when served.
117
+ if (i.providerMode === 'managed')
118
+ return i.served ?? 'static';
119
+ if (i.providerMode === 'byok')
120
+ return i.served ?? 'static';
121
+ // Unknown/fake provider modes keep today's full list.
122
+ return 'static';
123
+ }
124
+ /**
125
+ * Build the `tools[]` array. 'static' ⇒ exactly today's list
126
+ * (toAnthropicTools). 'deferred' ⇒ loaded entries (core ∪ starters, in
127
+ * registry order, byte-identical to today's entries), then every other tool
128
+ * with `defer_loading: true` (registry order), then the tool search tool —
129
+ * the search tool only when at least one entry is deferred.
130
+ */
131
+ export function buildToolList(tools, opts) {
132
+ if (opts.mode === 'static')
133
+ return toAnthropicTools(tools);
134
+ const loadedNames = new Set([...CORE_TOOLS, ...startersForSkill(opts.skill)]);
135
+ const loaded = tools.filter((t) => loadedNames.has(t.name));
136
+ const deferred = tools.filter((t) => !loadedNames.has(t.name));
137
+ // Final review I1 — nothing deferred ⇒ nothing to search: no search tool
138
+ // (plan audit's `toolFilter: () => false` means "no tools at all" ⇒ []).
139
+ if (deferred.length === 0)
140
+ return toAnthropicTools(loaded);
141
+ return [
142
+ ...toAnthropicTools(loaded),
143
+ ...toAnthropicTools(deferred).map((t) => ({ ...t, defer_loading: true })),
144
+ { ...TOOL_SEARCH_TOOL },
145
+ ];
146
+ }
147
+ /** Names of the entries a built list declares with `defer_loading: true`. */
148
+ export function deferredNames(toolList) {
149
+ const out = new Set();
150
+ for (const t of toolList) {
151
+ if (t && t.defer_loading === true && typeof t.name === 'string')
152
+ out.add(t.name);
153
+ }
154
+ return out;
155
+ }
156
+ /** True for the tool search entry of a built list (the one predicate; provider-shape re-exports it). */
157
+ export function isToolSearchEntry(t) {
158
+ return !!t && typeof t === 'object' && t.type === TOOL_SEARCH_TOOL.type;
159
+ }
160
+ /**
161
+ * Probe D2 (2026-09-27): the API rejects a `role: 'system'` message followed
162
+ * by a user message — "role 'system' must precede an 'assistant' message or
163
+ * end the array" (400). A tool_addition pushed after a tool-result message
164
+ * can be followed by a user message when the next round adds one first (mid-
165
+ * turn steering, the watchdog nudge) or when the turn ended before the model
166
+ * answered (refusal, interruption) and the next turn's prompt is pushed, and
167
+ * a later skill switch can then append a second tool_addition.
168
+ *
169
+ * Only the TAIL after the last assistant message is ever touched (the whole
170
+ * array when there is none): it holds only user and system messages, none of
171
+ * which an assistant message has followed yet, so no thinking block's prefix
172
+ * changes (at most the uncached tail is re-written). The tail is stably
173
+ * partitioned: user messages first (in order), then the system messages'
174
+ * blocks (in order) merged into ONE system message that ends the array — the
175
+ * shape probe D3 proved, its next round included. Merging avoids a
176
+ * [system, system] run, whose acceptance was never probed; one system message
177
+ * with several tool_addition blocks is the shape the CLI already sends for a
178
+ * skill's starters. Anything before the last assistant message is left
179
+ * exactly as it is. Mutates `messages`; returns the number of system messages
180
+ * moved or merged. Idempotent: a second pass is a no-op.
181
+ */
182
+ export function placeSystemMessagesInPlace(messages) {
183
+ if (!messages)
184
+ return 0;
185
+ let tailStart = messages.length;
186
+ while (tailStart > 0 && messages[tailStart - 1]?.role !== 'assistant')
187
+ tailStart--;
188
+ const tail = messages.slice(tailStart);
189
+ const users = tail.filter((m) => m?.role !== 'system');
190
+ const systems = tail.filter((m) => m?.role === 'system');
191
+ if (systems.length === 0)
192
+ return 0;
193
+ // Already in shape: users, then exactly one system message last.
194
+ if (systems.length === 1 && tail[tail.length - 1] === systems[0])
195
+ return 0;
196
+ const merged = {
197
+ role: 'system',
198
+ content: systems.flatMap((m) => (Array.isArray(m.content) ? m.content : [])),
199
+ };
200
+ messages.splice(tailStart, tail.length, ...users, merged);
201
+ return systems.length;
202
+ }
203
+ /**
204
+ * A `role: 'system'` message surfacing deferred tools by reference. Names not
205
+ * in `deferredSet` (loaded already, or unknown in this build) are dropped;
206
+ * nothing left ⇒ null.
207
+ */
208
+ export function buildToolAdditionMessage(names, deferredSet) {
209
+ const keep = [...new Set(names)].filter((n) => deferredSet.has(n));
210
+ if (keep.length === 0)
211
+ return null;
212
+ return {
213
+ role: 'system',
214
+ content: keep.map((name) => ({ type: 'tool_addition', tool: { type: 'tool_reference', name } })),
215
+ };
216
+ }
217
+ /**
218
+ * Integration finding N1 (c) — the tool_addition for a skill switch: the
219
+ * skill's starters that the CURRENT mode's list (`toolList`, the list the
220
+ * next request carries) declares deferred, minus those the conversation has
221
+ * already surfaced. A session pinned 'static' never gets one, whatever list
222
+ * the caller holds (a static request cannot carry a `role: 'system'`
223
+ * tool_addition). Nothing to add ⇒ null.
224
+ */
225
+ export function buildSkillToolAddition(i) {
226
+ if (i.toolLoading === 'static')
227
+ return null;
228
+ const surfaced = surfacedToolNames(i.messages);
229
+ return buildToolAdditionMessage(startersForSkill(i.skill).filter((n) => !surfaced.has(n)), deferredNames(i.toolList));
230
+ }
231
+ /**
232
+ * Tool names already surfaced in a conversation: every `tool_addition`
233
+ * reference (`tool.name`) and every search-result reference (`tool_name`).
234
+ * Used so a skill switch never re-adds a tool.
235
+ */
236
+ export function surfacedToolNames(messages) {
237
+ const out = new Set();
238
+ for (const m of messages ?? []) {
239
+ if (!Array.isArray(m?.content))
240
+ continue;
241
+ for (const b of m.content) {
242
+ if (b?.type === 'tool_addition' && typeof b.tool?.name === 'string')
243
+ out.add(b.tool.name);
244
+ if (b?.type === 'tool_search_tool_result') {
245
+ const refs = b.content?.tool_references;
246
+ if (Array.isArray(refs)) {
247
+ for (const r of refs)
248
+ if (typeof r?.tool_name === 'string')
249
+ out.add(r.tool_name);
250
+ }
251
+ }
252
+ }
253
+ }
254
+ return out;
255
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * tool_output_read — Task 17 ruling (2026-09-27).
3
+ *
4
+ * Large tool results are saved to ~/.cspeach/streams/<sessionId>/tool-<id>.txt
5
+ * (result-spill.ts) and the history keeps a preview naming this tool. It is
6
+ * always registered (not flag-gated — file_read is off in a plain SAP session),
7
+ * read-only, in CORE_TOOLS, and reads ONLY the current session's spill files
8
+ * (a reader child: also its parent session's — final review tools-I4): the
9
+ * path must be a tool-*.txt directly inside one of those spill dirs, and
10
+ * it is re-checked after realpath (a symlink cannot point it elsewhere).
11
+ * Anything else — including a ctx with no session id — is an error result.
12
+ * Its own result is never spilled; instead it has a char budget.
13
+ */
14
+ import { promises as fsp } from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { registerTool } from './index.js';
17
+ import { spillDirFor, sessionIdOf, SPILL_FILE_RE, NUMBERED_SPILL_RE, SPILL_MAX_AGE_DAYS, withRealpath, } from './result-spill.js';
18
+ const MAX_LINES = 2000;
19
+ /** Total characters one call returns (fix round 1, minor 2). */
20
+ export const TOOL_OUTPUT_READ_MAX_CHARS = 40_000;
21
+ /** One returned line never carries more than this. */
22
+ export const TOOL_OUTPUT_READ_LINE_MAX_CHARS = 2_000;
23
+ const REFUSED = 'not a saved tool output of this session';
24
+ const err = (msg) => ({ content: `error: ${msg}`, is_error: true });
25
+ /** Case-insensitive file systems (win32, darwin default) compare paths case-insensitively. */
26
+ const norm = (p) => (process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p);
27
+ /** The spill file itself must sit directly in one of the session-dir spellings. */
28
+ function directlyIn(dirs, file) {
29
+ const parent = norm(path.dirname(file));
30
+ return dirs.some((d) => norm(d) === parent);
31
+ }
32
+ function clip(line) {
33
+ return line.length <= TOOL_OUTPUT_READ_LINE_MAX_CHARS
34
+ ? line
35
+ : `${line.slice(0, TOOL_OUTPUT_READ_LINE_MAX_CHARS)}… [+${line.length - TOOL_OUTPUT_READ_LINE_MAX_CHARS} chars]`;
36
+ }
37
+ export async function toolOutputReadHandler(args, ctx) {
38
+ const sessionId = sessionIdOf(ctx);
39
+ if (sessionId === undefined)
40
+ return err(REFUSED);
41
+ const p = typeof args.path === 'string' ? args.path : '';
42
+ if (p.length === 0 || p.includes('\0') || !path.isAbsolute(p)) {
43
+ return err('path must be the absolute spill-file path shown in the tool result');
44
+ }
45
+ const offset = args.offset === undefined ? 0 : Number(args.offset);
46
+ const limitArg = args.limit === undefined ? MAX_LINES : Number(args.limit);
47
+ if (!Number.isInteger(offset) || offset < 0)
48
+ return err(`offset must be a non-negative integer (got ${String(args.offset)})`);
49
+ if (!Number.isInteger(limitArg) || limitArg < 1)
50
+ return err(`limit must be a positive integer (got ${String(args.limit)})`);
51
+ const limit = Math.min(limitArg, MAX_LINES);
52
+ // Final review tools-I4 — a reader child may also read its parent's saved
53
+ // outputs: that one dir only, and only on a reader ctx.
54
+ const parentId = ctx.readerMode && typeof ctx.spillParentSessionId === 'string' && ctx.spillParentSessionId.length > 0
55
+ ? ctx.spillParentSessionId
56
+ : undefined;
57
+ const dirs = [
58
+ ...withRealpath(spillDirFor(sessionId)),
59
+ ...(parentId !== undefined ? withRealpath(spillDirFor(parentId)) : []),
60
+ ];
61
+ const abs = path.resolve(p);
62
+ if (!SPILL_FILE_RE.test(path.basename(abs)) || !directlyIn(dirs, abs))
63
+ return err(REFUSED);
64
+ let real;
65
+ try {
66
+ real = await fsp.realpath(abs);
67
+ }
68
+ catch (e) {
69
+ return err(e.code === 'ENOENT'
70
+ ? `saved output not found (saved outputs are kept ${SPILL_MAX_AGE_DAYS} days)`
71
+ : 'cannot read saved output');
72
+ }
73
+ if (!SPILL_FILE_RE.test(path.basename(real)) || !directlyIn(dirs, real))
74
+ return err(REFUSED);
75
+ let raw;
76
+ try {
77
+ raw = await fsp.readFile(real, 'utf-8');
78
+ }
79
+ catch {
80
+ return err('cannot read saved output');
81
+ }
82
+ // A `.n.txt` spill (file_read) already carries the file's line numbers.
83
+ const numberedAlready = NUMBERED_SPILL_RE.test(real);
84
+ const all = raw.replace(/\n$/, '').split('\n');
85
+ const end = Math.min(all.length, offset + limit);
86
+ const out = [];
87
+ let used = 0;
88
+ let next = offset;
89
+ for (let i = offset; i < end; i++) {
90
+ const line = clip(numberedAlready ? all[i] : `${i + 1}\t${all[i]}`);
91
+ if (out.length > 0 && used + line.length + 1 > TOOL_OUTPUT_READ_MAX_CHARS)
92
+ break;
93
+ out.push(line);
94
+ used += line.length + 1;
95
+ next = i + 1;
96
+ }
97
+ const rest = all.length - next;
98
+ return { content: out.join('\n') + (rest > 0 ? `\n... (${rest} more lines — next offset=${next})` : '') };
99
+ }
100
+ registerTool({
101
+ name: 'tool_output_read',
102
+ description: 'Read a slice of a large tool result that was saved to a file (the result names the path). '
103
+ + 'Returns numbered lines. offset is 0-based; limit defaults to and caps at 2000 lines (40 000 chars). '
104
+ + 'Only this session\'s saved outputs can be read.',
105
+ isMutating: false,
106
+ category: 'filesystem',
107
+ input_schema: {
108
+ type: 'object',
109
+ properties: {
110
+ path: { type: 'string', description: 'The saved-output path from the tool result.' },
111
+ offset: { type: 'number', description: 'Optional 0-based line offset.' },
112
+ limit: { type: 'number', description: 'Optional max lines (cap 2000).' },
113
+ },
114
+ required: ['path'],
115
+ },
116
+ handler: toolOutputReadHandler,
117
+ });
@@ -16,7 +16,7 @@ import { auditLog } from '@cspeach/sap-client';
16
16
  import { ERR } from '../errors/codes.js';
17
17
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
18
18
  import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
19
- import { setCurrentTransport } from '../repl/current-transport.js';
19
+ import { setCurrentTransport, noteConfirmedTransport } from '../repl/current-transport.js';
20
20
  import { enrichTransportList } from './transport-fit.js';
21
21
  // ── Session ledger: transports CREATED by sap_transport_create this session ──
22
22
  //
@@ -286,6 +286,11 @@ registerTool({
286
286
  // Ownership-aware guidance (I1) — mirrors the create guard wording.
287
287
  const me = (ctx.adt.username ?? '').toUpperCase();
288
288
  const foreignOwner = r.owner && r.owner.toUpperCase() !== me ? r.owner : undefined;
289
+ // UI fix round (1.1.20): a lock the connected user may write under is
290
+ // a CONFIRMED transport for this work — the footer shows it (display
291
+ // only; the write pointer is untouched). A foreign lock is not ours.
292
+ if (!foreignOwner)
293
+ noteConfirmedTransport(r.transport);
289
294
  note = foreignOwner
290
295
  ? `${args.object_name} is locked in ${r.transport} (owned by ${foreignOwner}) — SAP `
291
296
  + 'only accepts changes under that request; ask the owner to add you as a task '
package/dist/ui/footer.js CHANGED
@@ -8,7 +8,7 @@ import { pasteClipboardImage } from './clipboard-image.js';
8
8
  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
- import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
11
+ import { frameWidth, truncateToWidth } from '../renderer/ui-width.js';
12
12
  import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
13
13
  import { getGlyphs } from '../renderer/glyphs.js';
14
14
  import { roleProps, selectedProps } from './role-props.js';
@@ -31,12 +31,12 @@ const PROMPT = `${PROMPT_GLYPH} `;
31
31
  /** Rounded border (2) + paddingX 1 on each side (2). */
32
32
  const BOX_CHROME = 4;
33
33
  /**
34
- * Width of the input box (and of the footer line under it). uiWidth() caps it
35
- * at 100; the −1 keeps the box off the exact-terminal-width deferred-wrap edge
36
- * on Windows Terminal (the margin the old single-row budget kept, Bug 1).
34
+ * Width of the input box (and of the footer line under it) — the FRAME width
35
+ * (frameWidth(): full terminal less the 1-column deferred-wrap margin, Bug 1).
36
+ * No 100-col cap: the frame spans the terminal; text inside wraps on its own.
37
37
  */
38
38
  export function inputBoxWidth(columns) {
39
- return Math.max(12, Math.min(uiWidth(columns), columns - 1));
39
+ return frameWidth(columns);
40
40
  }
41
41
  export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange, maxLines, onInputLinesChange, waitingForCard, }) {
42
42
  const [value, setValue] = useState('');
@@ -14,7 +14,7 @@ import React, { createContext, useContext } from 'react';
14
14
  const DEFAULT_STATE = {
15
15
  system: '', user: '', transport: null, locksHeld: [],
16
16
  tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 100, recentTurns: [],
17
- writeMode: null, lastWriteTransport: null,
17
+ writeMode: null, lastWriteTransport: null, confirmedTransport: null,
18
18
  // Credits default OFF: BYOK/local/ai-hub and any pre-resolution frame keep
19
19
  // the dollar rendering. Only an explicit managed+metered resolution flips it.
20
20
  creditsMode: false, creditsSessionTotal: 0, creditsBalance: null,
@@ -25,6 +25,35 @@ class TurnStatusEmitter extends EventEmitter {
25
25
  * of blanking the liveness row.
26
26
  */
27
27
  depth = 0;
28
+ /**
29
+ * UI fix round (CLI 1.1.20) — the turn CLOCK pauses while the user is the
30
+ * one being waited on. Live: a question card waited ~7 min and the receipt
31
+ * read "420.2s". Every blocking prompt already pauses this emitter (card
32
+ * slot withFocusClaim, the loop's interactive-tool pause, the save hook),
33
+ * so the emitter keeps the paused time: `pausedTotal` is a process-wide
34
+ * monotonic sum of closed pause intervals, `pauseBegan` the open one.
35
+ * Receipts snapshot pausedMs() at turn start and subtract the growth.
36
+ */
37
+ pausedTotal = 0;
38
+ pauseBegan = null;
39
+ /** pausedMs() when the current startedAt was set (the live row's base). */
40
+ pauseBase = 0;
41
+ closePause(now = Date.now()) {
42
+ if (this.pauseBegan === null)
43
+ return;
44
+ this.pausedTotal += Math.max(0, now - this.pauseBegan);
45
+ this.pauseBegan = null;
46
+ }
47
+ /** Total ms spent paused on user input so far (monotonic, includes an open pause). */
48
+ pausedMs(now = Date.now()) {
49
+ return this.pausedTotal + (this.pauseBegan === null ? 0 : Math.max(0, now - this.pauseBegan));
50
+ }
51
+ /** The live row's clock: time since the turn started, less time paused on the user. */
52
+ activeElapsedMs(now = Date.now()) {
53
+ if (!this.state.active)
54
+ return 0;
55
+ return Math.max(0, now - this.state.startedAt - (this.pausedMs(now) - this.pauseBase));
56
+ }
28
57
  /**
29
58
  * BUG-2b — mark a dispatch in-flight before the async pre-turn work runs.
30
59
  * Invisible (no spinner); isBusy() reports it so the submit guard can
@@ -79,6 +108,8 @@ class TurnStatusEmitter extends EventEmitter {
79
108
  if (this.depth === 0)
80
109
  this.turnCount++;
81
110
  this.depth++;
111
+ this.closePause();
112
+ this.pauseBase = this.pausedMs();
82
113
  this.state = { ...this.state, active: true, paused: false, label, startedAt: Date.now() };
83
114
  this.emit('change', this.state);
84
115
  }
@@ -87,6 +118,7 @@ class TurnStatusEmitter extends EventEmitter {
87
118
  activity(label) {
88
119
  if (!this.state.active)
89
120
  return;
121
+ this.closePause();
90
122
  this.state = { ...this.state, label, paused: false };
91
123
  this.emit('change', this.state);
92
124
  }
@@ -94,6 +126,7 @@ class TurnStatusEmitter extends EventEmitter {
94
126
  pause() {
95
127
  if (!this.state.active || this.state.paused)
96
128
  return;
129
+ this.pauseBegan = Date.now();
97
130
  this.state = { ...this.state, paused: true };
98
131
  this.emit('change', this.state);
99
132
  }
@@ -101,6 +134,7 @@ class TurnStatusEmitter extends EventEmitter {
101
134
  resume() {
102
135
  if (!this.state.active || !this.state.paused)
103
136
  return;
137
+ this.closePause();
104
138
  this.state = { ...this.state, paused: false };
105
139
  this.emit('change', this.state);
106
140
  }
@@ -114,6 +148,9 @@ class TurnStatusEmitter extends EventEmitter {
114
148
  this.depth--;
115
149
  if (this.depth > 0)
116
150
  return;
151
+ // A turn ended while a card waited (Ctrl+C / Esc on it): the wait was
152
+ // still the user's time, not the turn's.
153
+ this.closePause();
117
154
  this.state = { ...this.state, active: false, paused: false, label: '', startedAt: 0 };
118
155
  this.emit('change', this.state);
119
156
  }
@@ -67,5 +67,5 @@ export function TurnStatus() {
67
67
  // dynamic frame too — a long tool label must clip, never soft-wrap
68
68
  // (wrapped dynamic-frame rows break Ink's erase accounting and leak
69
69
  // stale rows into scrollback).
70
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { ...roleProps('sap'), children: [FRAMES[frame], " "] }), _jsx(Text, { children: state.label }), _jsxs(Text, { ...roleProps('dim'), children: [" \u00B7 ", fmtElapsed(now - state.startedAt), " \u00B7 ctrl+c to stop"] })] }));
70
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { ...roleProps('sap'), children: [FRAMES[frame], " "] }), _jsx(Text, { children: state.label }), _jsxs(Text, { ...roleProps('dim'), children: [" \u00B7 ", fmtElapsed(turnStatusEmitter.activeElapsedMs(now)), " \u00B7 ctrl+c to stop"] })] }));
71
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.19",
3
+ "version": "1.1.20",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -43,6 +43,7 @@
43
43
  "zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts",
44
44
  "bench:models": "tsx scripts/bench-models.ts",
45
45
  "bench:audit": "tsx scripts/bench-audit-task.ts",
46
+ "bench:replay": "tsx bench/replay/run.ts",
46
47
  "fiori": "tsx src/tools/fiori/bin.ts",
47
48
  "prepare": "husky",
48
49
  "prepublishOnly": "node -e \"const deps=require('./package.json').dependencies||{}; for (const n of ['@cspeach/sap-client','@cspeach/register-core']) { const d=deps[n]||''; if(d.startsWith('file:')||d.startsWith('link:')){console.error('publish blocked: '+n+' is a local link ('+d+') — point it at the published version before npm publish'); process.exit(1)} }\""