@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
@@ -4,6 +4,9 @@
4
4
  // The proxy serves `GET {proxy_url}/v1/model-config` (Task 3):
5
5
  // { roles: { session_default, audit, compact, router },
6
6
  // pricing: Record<model, { inputPer1M, outputPer1M, cacheReadPer1M, cacheCreatePer1M? }>,
7
+ // effort: { session_default?, audit?, compact? }, (Task 4; {} = none)
8
+ // tool_loading: 'deferred' | 'static', (Task 4)
9
+ // auto_compact_tokens: number | null, (Task 4; null = CLI default)
7
10
  // updated_at }
8
11
  // top-level keys snake_case, pricing entries camelCase, Cache-Control 60s, no auth.
9
12
  //
@@ -24,6 +27,11 @@
24
27
  import fs from 'node:fs/promises';
25
28
  import path from 'node:path';
26
29
  import { cspeachRoot } from '../config/paths.js';
30
+ export const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
31
+ const EFFORT_ROLE_KEYS = ['session_default', 'audit', 'compact'];
32
+ export function isEffort(v) {
33
+ return typeof v === 'string' && EFFORT_VALUES.includes(v);
34
+ }
27
35
  // ── Synchronous module snapshot ───────────────────────────────────────────────
28
36
  let snapshot = null;
29
37
  /** The current served-config snapshot, or null (⇒ built-ins win). Pure sync read. */
@@ -60,6 +68,27 @@ function coercePricing(raw) {
60
68
  ? raw
61
69
  : {};
62
70
  }
71
+ /** Keep only valid effort levels for the known effort roles. */
72
+ function coerceEffort(raw) {
73
+ const out = {};
74
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
75
+ return out;
76
+ const r = raw;
77
+ for (const k of EFFORT_ROLE_KEYS) {
78
+ if (isEffort(r[k]))
79
+ out[k] = r[k];
80
+ }
81
+ return out;
82
+ }
83
+ function coerceToolLoading(raw) {
84
+ return raw === 'deferred' || raw === 'static' ? raw : undefined;
85
+ }
86
+ /** Same bounds as the proxy column CHECK (migration 021). */
87
+ function coerceAutoCompactTokens(raw) {
88
+ return typeof raw === 'number' && Number.isInteger(raw) && raw >= 10_000 && raw <= 10_000_000
89
+ ? raw
90
+ : undefined;
91
+ }
63
92
  /**
64
93
  * Map a served (or cached) document to a ServerModelConfig, or null when the
65
94
  * shape is unusable. `fetchedAt`, when provided, is preserved (disk-cache read);
@@ -73,9 +102,14 @@ function coerceDocument(body, fetchedAt) {
73
102
  const roles = coerceRoles(b.roles);
74
103
  if (Object.keys(roles).length === 0)
75
104
  return null;
105
+ const toolLoading = coerceToolLoading(b.tool_loading);
106
+ const autoCompactTokens = coerceAutoCompactTokens(b.auto_compact_tokens);
76
107
  return {
77
108
  roles,
78
109
  pricing: coercePricing(b.pricing),
110
+ effort: coerceEffort(b.effort),
111
+ ...(toolLoading ? { tool_loading: toolLoading } : {}),
112
+ ...(autoCompactTokens !== undefined ? { auto_compact_tokens: autoCompactTokens } : {}),
79
113
  fetchedAt: typeof fetchedAt === 'string' && fetchedAt.length > 0 ? fetchedAt : new Date().toISOString(),
80
114
  };
81
115
  }
@@ -112,9 +146,11 @@ async function readFreshDiskCache() {
112
146
  // ── Public entrypoint ─────────────────────────────────────────────────────────
113
147
  /**
114
148
  * Fetch the served model-config and update the module snapshot. Managed mode
115
- * ONLY. Fire-and-forget from startup; NEVER throws.
149
+ * ONLY. NEVER throws. Startup awaits it with `timeoutMs: 1500` (Task 3) so the
150
+ * session model is pinned from the served config before call 0; a slower
151
+ * fetch aborts and falls back to a fresh disk cache. Default cap: 5 s.
116
152
  */
117
- export async function loadServerModelConfig(cfg) {
153
+ export async function loadServerModelConfig(cfg, opts) {
118
154
  // Non-managed modes bypass the proxy for inference and must never fetch.
119
155
  if (cfg.llm?.mode !== 'managed')
120
156
  return;
@@ -125,7 +161,7 @@ export async function loadServerModelConfig(cfg) {
125
161
  // rejection that crashes startup. Keep it caught like every other failure.
126
162
  const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/model-config`;
127
163
  const controller = new AbortController();
128
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
164
+ const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
129
165
  timer.unref?.();
130
166
  let body;
131
167
  try {
@@ -153,3 +189,122 @@ export async function loadServerModelConfig(cfg) {
153
189
  snapshot = cached;
154
190
  }
155
191
  }
192
+ let customerSnapshot = null;
193
+ /** The current customer snapshot, or null (⇒ layer skipped). Pure sync read. */
194
+ export function getCustomerModelConfig() {
195
+ return customerSnapshot;
196
+ }
197
+ /** Test-only setter for the customer snapshot. */
198
+ export function __setCustomerModelConfigForTests(c) {
199
+ customerSnapshot = c;
200
+ }
201
+ function coerceCustomer(body) {
202
+ if (!body || typeof body !== 'object' || Array.isArray(body))
203
+ return null;
204
+ const b = body;
205
+ const model = typeof b.model === 'string' && b.model.trim().length > 0 ? b.model.trim() : null;
206
+ return {
207
+ model,
208
+ effort: isEffort(b.effort) ? b.effort : null,
209
+ enforced: b.enforced === true,
210
+ };
211
+ }
212
+ /**
213
+ * Fetch the caller's customer model-config and update the customer snapshot.
214
+ * NEVER throws; on any failure the snapshot is null.
215
+ */
216
+ export async function loadCustomerModelConfig(cfg, getBearer, opts) {
217
+ customerSnapshot = null;
218
+ const controller = new AbortController();
219
+ const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
220
+ timer.unref?.();
221
+ try {
222
+ // The keychain read shares the cap: a hanging getBearer loses the race to
223
+ // the same timer that aborts the fetch (review M1).
224
+ const aborted = new Promise((_resolve, reject) => {
225
+ controller.signal.addEventListener('abort', () => reject(new Error('timeout')), { once: true });
226
+ });
227
+ aborted.catch(() => { });
228
+ const key = await Promise.race([getBearer(), aborted]);
229
+ if (!key)
230
+ return;
231
+ const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/me/model-config`;
232
+ const r = await fetch(url, {
233
+ headers: { Authorization: `Bearer ${key}` },
234
+ signal: controller.signal,
235
+ });
236
+ if (!r.ok)
237
+ return;
238
+ customerSnapshot = coerceCustomer(await r.json());
239
+ }
240
+ catch {
241
+ customerSnapshot = null;
242
+ }
243
+ finally {
244
+ clearTimeout(timer);
245
+ }
246
+ }
247
+ // ── Tool-loading kill switch (Task 16, ruling F13) ───────────────────────────
248
+ //
249
+ // Managed mode reads `tool_loading` from the served snapshot above. BYOK never
250
+ // loads that snapshot (its roles must not steer a BYOK session), so it reads
251
+ // ONLY `tool_loading` from the same public endpoint — and only when signed in
252
+ // (a CSPeach bearer exists). No bearer, any failure, or an invalid value ⇒
253
+ // undefined, which resolveToolLoading turns into 'static' for BYOK. No disk
254
+ // cache. NEVER throws. Startup runs it alongside the other loads under the
255
+ // same 1.5 s cap.
256
+ let byokToolLoading;
257
+ /** Test-only setter for the BYOK tool-loading value. */
258
+ export function __setByokToolLoadingForTests(v) {
259
+ byokToolLoading = v;
260
+ }
261
+ /**
262
+ * The served tool-loading mode: the managed snapshot's `tool_loading`, else
263
+ * the BYOK fetch result, else undefined. Pure sync read.
264
+ */
265
+ export function servedToolLoading() {
266
+ return snapshot?.tool_loading ?? byokToolLoading;
267
+ }
268
+ /**
269
+ * Task 21 fix round (controller ruling) — the managed proxy's capability
270
+ * signal: its served config carries `tool_loading` (migration 021 and later).
271
+ * An older proxy serves no such field and adds none of the new betas, so the
272
+ * loop sends the new wire shapes (deferred tools, tool search, tool_addition,
273
+ * `thinking.block_binding`) on the managed path only when this is true.
274
+ * Managed snapshot only; BYOK never reads it. Pure sync read.
275
+ */
276
+ export function managedProxyServesNewShapes() {
277
+ return snapshot?.tool_loading !== undefined;
278
+ }
279
+ /** BYOK only: fetch `/v1/model-config` and keep its `tool_loading`. */
280
+ export async function fetchToolLoadingMode(cfg, getBearer, opts) {
281
+ byokToolLoading = undefined;
282
+ if (cfg.llm?.mode !== 'byok')
283
+ return undefined;
284
+ const controller = new AbortController();
285
+ const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
286
+ timer.unref?.();
287
+ try {
288
+ const aborted = new Promise((_resolve, reject) => {
289
+ controller.signal.addEventListener('abort', () => reject(new Error('timeout')), { once: true });
290
+ });
291
+ aborted.catch(() => { });
292
+ const key = await Promise.race([getBearer(), aborted]);
293
+ if (!key)
294
+ return undefined;
295
+ const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/model-config`;
296
+ const r = await fetch(url, { signal: controller.signal });
297
+ if (!r.ok)
298
+ return undefined;
299
+ const body = (await r.json());
300
+ byokToolLoading = coerceToolLoading(body && typeof body === 'object' ? body.tool_loading : undefined);
301
+ return byokToolLoading;
302
+ }
303
+ catch {
304
+ byokToolLoading = undefined;
305
+ return undefined;
306
+ }
307
+ finally {
308
+ clearTimeout(timer);
309
+ }
310
+ }
package/dist/one-shot.js CHANGED
@@ -12,7 +12,7 @@ import { AdtClient } from '@cspeach/sap-client';
12
12
  import { makeSessionId, saveSession } from './session/store.js';
13
13
  import { newSession } from './session/schema.js';
14
14
  import { resolveModelRole } from './models/resolve.js';
15
- import { loadServerModelConfig } from './models/server-config.js';
15
+ import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
16
16
  import { createProviderForMode } from './agent/providers/factory.js';
17
17
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
18
18
  import { runTurn } from './agent/loop.js';
@@ -44,6 +44,7 @@ import './tools/ask-question.js';
44
44
  import './tools/dispatch-skill.js';
45
45
  import './tools/todo.js';
46
46
  import './tools/filesystem/file-read.js';
47
+ import './tools/tool-output-read.js';
47
48
  import './tools/filesystem/read-document.js';
48
49
  import './tools/filesystem/file-edit.js';
49
50
  import './tools/filesystem/file-write.js';
@@ -65,6 +66,7 @@ import './tools/subagent/background_run.js';
65
66
  import './tools/subagent/monitor_emit.js';
66
67
  import './tools/subagent/schedule_draft_create.js';
67
68
  import './tools/subagent/agent_run.js';
69
+ import './tools/subagent/read_agent.js';
68
70
  export async function runOneShot(prompt, opts) {
69
71
  applyColorMode(); // NO_COLOR (piece 2 §1.2)
70
72
  const cfg = await loadConfig();
@@ -78,10 +80,18 @@ export async function runOneShot(prompt, opts) {
78
80
  setRenderingOverride(resolveRenderingMode(cfg.ui.rendering).mode);
79
81
  setLook(cfg.ui.look);
80
82
  setBackground(cfg.ui.background);
81
- // model-governance step 2d — fire-and-forget fetch of the served model-config
82
- // (managed mode only, never blocks). A turn that starts before it lands uses
83
- // the built-ins (fail-safe).
84
- void loadServerModelConfig(cfg);
83
+ // model-governance step 2d — fetch the served model-config (managed mode
84
+ // only). Task 3: awaited, but startup waits at most 1.5 s (then a fresh disk
85
+ // cache, else the built-ins), so the session model pinned at newSession
86
+ // below comes from the served config. Never throws.
87
+ // Task 12 — the customer layer (/v1/me/model-config, every mode with a
88
+ // key) loads in parallel under the SAME 1.5 s cap. Fails open.
89
+ await Promise.all([
90
+ loadServerModelConfig(cfg, { timeoutMs: 1500 }),
91
+ loadCustomerModelConfig(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
92
+ // Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
93
+ fetchToolLoadingMode(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
94
+ ]);
85
95
  // B5 (2026-06-11) — one-shot is headless by design: a single turn, no
86
96
  // REPL, often piped (CI / battery). Any stdin prompt would hang forever
87
97
  // (defect D1). The headless answer policy (ask_question auto-answers,
@@ -135,6 +135,14 @@ export function buildUserContent(text, images) {
135
135
  * hits the 32 MB request cap (and stops paying for stale screenshots).
136
136
  */
137
137
  export const MAX_HISTORY_IMAGES = 10;
138
+ /**
139
+ * Task 7 — stub images in batches, not one per turn. Each stub edits an
140
+ * earlier user message, and Opus 5.5 binds every later thinking block to the
141
+ * exact prefix that produced it, so each trim invalidates them (and the prompt
142
+ * cache). Once over `max`, the live count drops to `max - IMAGE_TRIM_BATCH + 1`
143
+ * (6 at max 10), so the next trim is IMAGE_TRIM_BATCH images away.
144
+ */
145
+ export const IMAGE_TRIM_BATCH = 5;
138
146
  /** Mutates `messages`; returns how many image blocks were removed. */
139
147
  export function trimHistoryImages(messages, max = MAX_HISTORY_IMAGES) {
140
148
  const slots = [];
@@ -146,10 +154,15 @@ export function trimHistoryImages(messages, max = MAX_HISTORY_IMAGES) {
146
154
  slots.push({ blocks: m.content, i });
147
155
  });
148
156
  }
149
- const excess = slots.length - max;
157
+ if (slots.length <= max)
158
+ return 0;
159
+ // Ruling F11: stub slots.length − (max − batch + 1) ⇒ 11 images at max 10
160
+ // stub the oldest 5. Clamped for a tiny `max` (never keep fewer than 1).
161
+ const keep = Math.max(1, max - IMAGE_TRIM_BATCH + 1);
162
+ const excess = slots.length - keep;
150
163
  for (let k = 0; k < excess; k++) {
151
164
  const { blocks, i } = slots[k];
152
165
  blocks[i] = { type: 'text', text: '[image removed from history]' };
153
166
  }
154
- return Math.max(0, excess);
167
+ return excess;
155
168
  }
@@ -23,12 +23,16 @@ export function formatSpendSegment(s) {
23
23
  return CREDITS_PENDING;
24
24
  return formatCredits(s.creditsSessionTotal, s.creditsUnitLabel);
25
25
  }
26
- /** Store → footer facts. F-L2-1: the transport falls back to the one a write actually stamped. */
26
+ /**
27
+ * Store → footer facts. F-L2-1: the transport falls back to the one a write
28
+ * actually stamped; UI fix round (1.1.20): then to one a transport lookup
29
+ * confirmed. A transport the user only typed is never shown.
30
+ */
27
31
  export function footerStateFrom(s) {
28
32
  return {
29
33
  system: s.system || undefined,
30
34
  user: s.user || undefined,
31
- transport: (s.transport ?? s.lastWriteTransport) || undefined,
35
+ transport: (s.transport ?? s.lastWriteTransport ?? s.confirmedTransport) || undefined,
32
36
  standalone: s.standalone === true,
33
37
  mode: s.writeMode ?? '',
34
38
  spend: formatSpendSegment(s),
@@ -9,7 +9,7 @@ import stringWidth from 'string-width';
9
9
  import { getLook } from './look.js';
10
10
  import { paint, BRAND_HEX } from './theme.js';
11
11
  import { formatAttention } from './notice-shape.js';
12
- import { truncateToWidth, uiWidth } from './ui-width.js';
12
+ import { truncateToWidth, uiWidth, frameWidth } from './ui-width.js';
13
13
  import { sanitizeControlChars } from './sanitize.js';
14
14
  import { holdBrand } from './brand-settled.js';
15
15
  export { brandSettled } from './brand-settled.js';
@@ -144,9 +144,11 @@ export function panelsStartTail(p) {
144
144
  const head = `${panelsStatusLine(p.status)}\n${p.third}\n`;
145
145
  return p.notices.length === 0 ? head + '\n' : head + '\n' + p.notices.join('') + '\n';
146
146
  }
147
- export function formatPromptEcho(text, width) {
147
+ export function formatPromptEcho(text, columns) {
148
148
  if (getLook() !== 'panels') {
149
149
  return chalk.dim('\n──────────────────────────────────\n') + chalk.cyan(`› ${text}\n`);
150
150
  }
151
- return '\n' + paint('frame', '─'.repeat(Math.min(60, Math.max(20, width - 4)))) + '\n' + paint('dim', 'you ›') + ` ${text}\n`;
151
+ // UI fix round (1.1.20): the rule is the FRAME width — as wide as the input
152
+ // box (frameWidth), not the old fixed 60. `columns` is the terminal width.
153
+ return '\n' + paint('frame', '─'.repeat(frameWidth(columns))) + '\n' + paint('dim', 'You ›') + ` ${text}\n`;
152
154
  }
@@ -1,3 +1,4 @@
1
+ import { spillLabelFor } from '../lib/spill-labels.js';
1
2
  const OBJECT_MAX = 60;
2
3
  /**
3
4
  * Every value shown in a label goes through here: ANSI escape sequences and
@@ -48,8 +49,25 @@ const activateObject = (a) => {
48
49
  const shown = list.slice(0, list.length > 3 ? 2 : 3).join(', ');
49
50
  return list.length > 3 ? `${shown} +${list.length - 2} more` : shown;
50
51
  };
51
- /** Length of a JSON array result (e.g. sap_search_object); undefined when it is not one — never a guess. */
52
+ /** One sap_search_object hit line (Task 17): NAME TYPE PACKAGE DESCRIPTION URI ("-" when empty). */
53
+ const SEARCH_HIT_RE = /^\S+ {2}\S+ {2}\S+ {2}.+ {2}(\/\S*|-)$/;
54
+ /** A spilled result's pointer line (tools/result-spill.ts): "[N lines / M chars — full output saved to …]". */
55
+ const SPILL_POINTER_RE = /^\[(\d+) lines \/ \d+ chars — full output saved to .*\]$/;
56
+ /**
57
+ * Hit count of a sap_search_object result: the Task 17 line format (a spilled
58
+ * preview counts from its pointer line), or a legacy JSON array. undefined
59
+ * when it is neither — never a guess.
60
+ */
52
61
  const arrayCount = (r) => {
62
+ if (r === '(no hits)')
63
+ return 0;
64
+ const lines = r.split('\n');
65
+ // Only the LAST line is the pointer — a hit description can't fake a count.
66
+ const spilled = SPILL_POINTER_RE.exec(lines[lines.length - 1] ?? '');
67
+ if (spilled)
68
+ return Number(spilled[1]);
69
+ if (lines.length > 0 && lines.every((l) => SEARCH_HIT_RE.test(l)))
70
+ return lines.length;
53
71
  try {
54
72
  const v = JSON.parse(r);
55
73
  return Array.isArray(v) ? v.length : undefined;
@@ -62,7 +80,19 @@ const arrayCount = (r) => {
62
80
  const SEARCH_DEFAULT_MAX = 100;
63
81
  const fileCount = (v) => (Array.isArray(v) ? `${v.length} ${v.length === 1 ? 'file' : 'files'}` : '');
64
82
  const commandLine = (a) => [str(a['command']) ?? '', ...(Array.isArray(a['args']) ? a['args'].map(str).filter((x) => x !== undefined) : [])].join(' ').trim();
83
+ /** Never the internal spill path: the object the output came from, else "saved output". */
84
+ const savedOutputLabel = (a) => {
85
+ const from = spillLabelFor(raw(a['path']) ?? ''); // lookup only — never displayed
86
+ const offset = typeof a['offset'] === 'number' ? a['offset'] : undefined;
87
+ const limit = typeof a['limit'] === 'number' ? a['limit'] : undefined;
88
+ const range = offset !== undefined && limit !== undefined ? `lines ${offset + 1}–${offset + limit}`
89
+ : offset !== undefined ? `from line ${offset + 1}` : undefined;
90
+ if (from === undefined)
91
+ return { verb: 'Read', object: 'saved output', ...(range ? { detail: range } : {}) };
92
+ return { verb: 'Read', object: from, detail: range ? `saved output · ${range}` : 'saved output' };
93
+ };
65
94
  const RULES = {
95
+ tool_output_read: (a) => savedOutputLabel(a),
66
96
  file_read: (a, r) => ({ verb: 'Read', object: shortLeft(str(a['path']) ?? ''), ...(r !== undefined ? { detail: `${lineCount(r)} lines` } : {}) }),
67
97
  file_write: (a) => ({ verb: 'Wrote', object: shortLeft(str(a['path']) ?? ''), detail: `${lineCount(raw(a['content']) ?? '')} lines` }),
68
98
  file_edit: (a) => ({
@@ -152,6 +182,7 @@ const RULES = {
152
182
  upgrade_merge_progress: (a) => ({ verb: 'Merged progress', object: fileCount(a['inputs']) }),
153
183
  monitor_emit: (a) => ({ verb: 'Checked background', object: shortRight(str(a['bg_id']) ?? '') }),
154
184
  agent_run: (a) => ({ verb: 'Subagent', object: shortRight(str(a['skill']) ?? '') }),
185
+ read_agent: (a) => ({ verb: 'Reader', object: shortRight(str(a['brief']) ?? '') }),
155
186
  schedule_draft_create: (a) => ({ verb: 'Drafted schedule', object: shortRight(str(a['when']) ?? '') }),
156
187
  fiori_scaffold: (a) => ({ verb: 'Scaffolded app', object: shortLeft(str(a['basePath']) ?? '') }),
157
188
  fiori_apply: (a) => ({ verb: 'Applied', object: shortRight([str(a['entry']), str(a['appDir'])].filter(Boolean).join(' · ')) }),
@@ -221,7 +252,7 @@ const PRESENT = {
221
252
  'Searched web': 'Searching web', 'Read project context': 'Reading project context',
222
253
  'Read playbook': 'Reading playbook', 'Read convention': 'Reading convention', 'Merged CCA': 'Merging CCA',
223
254
  'Merged progress': 'Merging progress', 'Checked background': 'Checking background',
224
- Subagent: 'Running subagent', 'Drafted schedule': 'Drafting schedule', Applied: 'Applying',
255
+ Subagent: 'Running subagent', Reader: 'Running reader', 'Drafted schedule': 'Drafting schedule', Applied: 'Applying',
225
256
  'Listed patterns': 'Listing patterns', 'Deploy config': 'Writing deploy config', 'Extended app': 'Extending app',
226
257
  Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
227
258
  'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
@@ -11,6 +11,19 @@ export function uiWidth(columns) {
11
11
  const cols = columns ?? process.stdout.columns ?? 80;
12
12
  return Math.min(UI_MAX_WIDTH, Math.max(UI_MIN_WIDTH, cols));
13
13
  }
14
+ /** Narrowest the frame ever gets (the input box needs its chrome + a few cells). */
15
+ export const FRAME_MIN_WIDTH = 12;
16
+ /**
17
+ * The FRAME width (UI fix round, CLI 1.1.20): the input box, the footer line
18
+ * under it and the rule above each `You ›` echo span the whole terminal —
19
+ * no 100-column cap. The −1 keeps the frame off the exact-terminal-width
20
+ * deferred-wrap edge on Windows Terminal. Text (answers, tool rows, card
21
+ * bodies) still wraps at uiWidth() so it stays readable on wide screens.
22
+ */
23
+ export function frameWidth(columns) {
24
+ const cols = columns ?? process.stdout.columns ?? 80;
25
+ return Math.max(FRAME_MIN_WIDTH, cols - 1);
26
+ }
14
27
  const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
15
28
  /**
16
29
  * Cut PLAIN text (no ANSI; paint after truncating) to `width` terminal cells.
@@ -57,6 +57,19 @@ export function noteWriteTransport(trkorr) {
57
57
  return;
58
58
  globalStore.update({ lastWriteTransport: trkorr });
59
59
  }
60
+ /**
61
+ * Footer-only wire (UI fix round, 1.1.20): record a transport a read-only
62
+ * lookup CONFIRMED (sap_transport_for_object found the object locked in one
63
+ * open transport the connected user may write to). Live incident: the user
64
+ * named S4HK903731, the lookup confirmed it, and the footer still read
65
+ * "no transport". Like noteWriteTransport it never touches the session write
66
+ * pointer — the lookup is read-only and must not change where writes go.
67
+ */
68
+ export function noteConfirmedTransport(trkorr) {
69
+ if (!trkorr)
70
+ return;
71
+ globalStore.update({ confirmedTransport: trkorr });
72
+ }
60
73
  /**
61
74
  * Handles the `/transport [<trkorr>|clear]` slash command. Returns user-facing
62
75
  * status text. Mirrors the shape of handleSafetyModeCommand in safety-mode-state.ts.
@@ -20,6 +20,7 @@
20
20
  import { computeCost } from '../cost/pricing.js';
21
21
  import { computeSessionCost } from '../cost/session-cost.js';
22
22
  import { globalStore } from '../ui/sap-state-store.js';
23
+ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
23
24
  import { globalCredits } from '../cost/credits.js';
24
25
  import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
25
26
  import { shouldUseInk } from '../renderer/tty.js';
@@ -32,8 +33,14 @@ export function snapshotTurnStart(session) {
32
33
  cacheRead: session.usage.cache_read_input_tokens,
33
34
  cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
34
35
  startedAt: Date.now(),
36
+ pausedMs: turnStatusEmitter.pausedMs(),
35
37
  };
36
38
  }
39
+ /** Wall-clock since the turn started, less the time it spent waiting on the user. */
40
+ export function turnElapsedMs(snapshot, now = Date.now()) {
41
+ const waited = snapshot.pausedMs === undefined ? 0 : turnStatusEmitter.pausedMs(now) - snapshot.pausedMs;
42
+ return Math.max(0, now - snapshot.startedAt - waited);
43
+ }
37
44
  /**
38
45
  * The per-turn numbers every receipt prints — extracted (piece 2 I3a) so the
39
46
  * REPL receipt and the one-shot receipt compute them the same way.
@@ -54,7 +61,7 @@ export function computeTurnFacts(session, snapshot, turnModel) {
54
61
  turnCost,
55
62
  // F-L2-2: cost/session-cost.ts so the agent loop can push the SAME total mid-stream.
56
63
  sessionCost: computeSessionCost(session),
57
- elapsedMs: Date.now() - snapshot.startedAt,
64
+ elapsedMs: turnElapsedMs(snapshot),
58
65
  };
59
66
  }
60
67
  export function pushPostTurnStatus(params) {