@cspeach/cli 1.1.19 → 1.1.21

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 +80 -80
  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,178 @@
1
+ /**
2
+ * read_agent — Task 18 (model control, 2026-09-27, spec D18).
3
+ *
4
+ * Heavy reading (many sources, a where-used walk, ATC-finding triage, a
5
+ * package inventory) runs in a lean child conversation and only a short
6
+ * report returns to the parent's history. Unlike agent_run (flag-gated, a
7
+ * full skill prefix per child), a reader carries:
8
+ * - a ~300-token system prompt owned by the proxy (skill header `_reader`,
9
+ * ruling F8; BYOK / AI-hub use the pinned copy in reader-prompt.ts),
10
+ * - a fixed small tool list (READER_TOOLS: read-only, no tool search),
11
+ * - the session model at effort `low`, role `reader` (never enforced, F7),
12
+ * - a capped report: overflow is saved to a file and the tail is cut.
13
+ *
14
+ * Read-only and depth 1 are enforced by `ctx.readerMode` at dispatch (F22).
15
+ * The loop runs consecutive read_agent calls concurrently, each with its own
16
+ * ctx clone whose `adt` is serialized through one lock (F21). The child's
17
+ * spend is added to the parent's per-turn `readerSpend` (F24); the child also
18
+ * writes its own reader-*-cost.jsonl.
19
+ */
20
+ import { EventEmitter } from 'node:events';
21
+ import { listTools, registerTool, toolsForContext } from '../index.js';
22
+ import { runTurn } from '../../agent/loop.js';
23
+ import { collectTurnAssistantText } from '../../agent/turn-assistant-text.js';
24
+ import { newSession } from '../../session/schema.js';
25
+ import { computeSessionCost } from '../../cost/session-cost.js';
26
+ import { sessionIdOf, spillIfLarge } from '../result-spill.js';
27
+ import { INTERRUPTED_LINE, READER_SKILL, READER_SYSTEM_PROMPT, READER_TOOLS, hasReaderReadTools } from './reader-prompt.js';
28
+ export { INTERRUPTED_LINE, READER_SKILL, READER_SYSTEM_PROMPT, READER_TOOLS, hasReaderReadTools };
29
+ export const BRIEF_MAX_CHARS = 4_000;
30
+ export const DEFAULT_REPORT_MAX_CHARS = 3_000;
31
+ export const HARD_REPORT_MAX_CHARS = 6_000;
32
+ /**
33
+ * Final review tools-I4 — told to every reader. The proxy owns the reader's
34
+ * system prompt (pinned byte-equal), so the CLI says this in the child's
35
+ * user message.
36
+ */
37
+ export const PARENT_OUTPUT_LINE = 'A saved-output path in this brief (a scan or ATC result the main session already ran) can be read with ' +
38
+ 'tool_output_read. Read it instead of repeating the scan: never re-run ATC or a scan whose output you were given.';
39
+ /** The result when the account's proxy refuses the `_reader` skill. */
40
+ export const NOT_ENABLED_LINE = 'readers are not enabled for this account — read the sources directly';
41
+ function reportCap(raw) {
42
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 1)
43
+ return DEFAULT_REPORT_MAX_CHARS;
44
+ return Math.min(Math.floor(raw), HARD_REPORT_MAX_CHARS);
45
+ }
46
+ function isNotEntitled(err) {
47
+ const e = err;
48
+ if (e?.error?.error === 'skill_not_in_entitlements')
49
+ return true;
50
+ return typeof e?.message === 'string' && e.message.includes('skill_not_in_entitlements');
51
+ }
52
+ export async function readAgentHandler(args, ctx) {
53
+ const brief = args?.brief;
54
+ if (typeof brief !== 'string' || brief.trim().length === 0) {
55
+ return { content: "error: 'brief' is required: say what to read and what to report.", is_error: true };
56
+ }
57
+ if (brief.length > BRIEF_MAX_CHARS) {
58
+ return { content: `error: 'brief' is ${brief.length} chars; keep it under ${BRIEF_MAX_CHARS}.`, is_error: true };
59
+ }
60
+ // Depth 1 (dispatch refuses this too; belt and braces for direct calls).
61
+ if (ctx.readerMode) {
62
+ return { content: 'error: a reader cannot start another reader.', is_error: true };
63
+ }
64
+ // Fix M4 — an agent_run child does its own reading.
65
+ if (ctx.subagent) {
66
+ return { content: 'error: a subagent cannot start a reader — read the sources directly.', is_error: true };
67
+ }
68
+ // Fix M5 — nothing a reader could read here (standalone, file tools off).
69
+ if (!hasReaderReadTools(new Set(toolsForContext(listTools(), ctx).map((t) => t.name)))) {
70
+ return { content: 'error: no read tools are available to a reader here — read the sources directly.', is_error: true };
71
+ }
72
+ if (ctx.signal?.aborted)
73
+ return { content: INTERRUPTED_LINE, is_error: true };
74
+ if (!ctx.provider) {
75
+ return { content: 'error: read_agent requires ctx.provider — not wired into this ToolContext.', is_error: true };
76
+ }
77
+ const cap = reportCap(args.report_max_chars);
78
+ // The session model, never a cheaper one (owner decision 11); effort low.
79
+ const model = ctx.session.model;
80
+ const childSession = newSession(`reader-${Date.now()}-${Math.floor(Math.random() * 1e9).toString(16)}`, ctx.session.sap_system ?? null, READER_SKILL, model);
81
+ // Only what a read needs. No previewHook (no writes), no chunkEmitter /
82
+ // todoEmitter (the child never paints the parent UI), no pendingDispatch /
83
+ // currentTransport (the child never changes parent REPL state).
84
+ const parentSessionId = sessionIdOf(ctx);
85
+ const childCtx = {
86
+ adt: ctx.adt,
87
+ sapAlias: ctx.sapAlias,
88
+ session: childSession,
89
+ cwd: ctx.cwd,
90
+ provider: ctx.provider,
91
+ skillSource: ctx.skillSource,
92
+ readerMode: true,
93
+ // Final review tools-I4 — the reader may open the parent's saved outputs.
94
+ ...(parentSessionId !== undefined ? { spillParentSessionId: parentSessionId } : {}),
95
+ };
96
+ const userMessage = `${brief}\n\n${PARENT_OUTPUT_LINE}\n\nReport limit: ${cap} characters.`;
97
+ let failure = null;
98
+ try {
99
+ await runTurn({
100
+ provider: ctx.provider,
101
+ userMessage,
102
+ skill: READER_SKILL,
103
+ ctx: childCtx,
104
+ // Null sink: the child's text lands in childSession.messages only.
105
+ chunkEmitter: new EventEmitter(),
106
+ modelRole: 'reader',
107
+ toolsOverride: READER_TOOLS,
108
+ effortOverride: 'low',
109
+ modelOverride: model,
110
+ suppressSaveHook: true,
111
+ // Fix I1 — Esc / Ctrl+C on the parent turn stops the reader's stream.
112
+ signal: ctx.signal,
113
+ });
114
+ }
115
+ catch (err) {
116
+ failure = err;
117
+ }
118
+ finally {
119
+ // Spend counts whether or not the reader finished.
120
+ if (ctx.readerSpend) {
121
+ ctx.readerSpend.cost += computeSessionCost(childSession);
122
+ ctx.readerSpend.calls += 1;
123
+ }
124
+ }
125
+ if (failure !== null) {
126
+ if (ctx.signal?.aborted)
127
+ return { content: INTERRUPTED_LINE, is_error: true };
128
+ if (isNotEntitled(failure))
129
+ return { content: NOT_ENABLED_LINE, is_error: true };
130
+ const msg = failure instanceof Error ? failure.message : String(failure);
131
+ return { content: `error: the reader failed — ${msg.slice(0, 300)}`, is_error: true };
132
+ }
133
+ const text = collectTurnAssistantText(childSession.messages, 0).trim();
134
+ if (text.length === 0) {
135
+ return {
136
+ content: 'error: the reader returned no report — read the sources directly.',
137
+ is_error: true,
138
+ };
139
+ }
140
+ if (text.length <= cap)
141
+ return { content: text };
142
+ // Over the cap: the full report goes to the PARENT's spill area under the
143
+ // parent's tool_use id, where tool_output_read can open it.
144
+ const head = text.slice(0, cap);
145
+ const sessionId = sessionIdOf(ctx);
146
+ const spilled = sessionId !== undefined
147
+ ? spillIfLarge({ text, sessionId, toolUseId: ctx.toolUseId, kind: 'report', threshold: cap })
148
+ : null;
149
+ const where = spilled?.spilled ? ` — full report saved to ${spilled.path}` : '';
150
+ return { content: `${head}\n[report cut at ${cap} chars${where}]` };
151
+ }
152
+ registerTool({
153
+ name: 'read_agent',
154
+ description: 'Hand heavy reading to a lean read-only reader; only its short report comes back. Use it for more ' +
155
+ 'than two source reads, a where-used scan, ATC-finding triage or a package inventory when the turn ' +
156
+ 'will keep working afterwards. The reader has read-only SAP and file tools; it cannot write, ask the ' +
157
+ 'user or start another reader. Give a precise brief: what to read and what to report. To hand over ' +
158
+ 'a result you already have (ATC findings), put its saved-output path in the brief: the reader reads it ' +
159
+ 'and never re-runs the scan. Send ' +
160
+ 'independent readers in one response; they run at the same time.',
161
+ isMutating: false,
162
+ category: 'subagent',
163
+ input_schema: {
164
+ type: 'object',
165
+ properties: {
166
+ brief: {
167
+ type: 'string',
168
+ description: 'What to read (objects, includes, packages) and what to report. Max 4000 characters.',
169
+ },
170
+ report_max_chars: {
171
+ type: 'number',
172
+ description: 'Report length limit. Default 3000, max 6000. Longer reports are cut and saved to a file.',
173
+ },
174
+ },
175
+ required: ['brief'],
176
+ },
177
+ handler: (args, ctx) => readAgentHandler(args, ctx),
178
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Task 18 (model control, 2026-09-27, spec D18, ruling F8) — the reader
3
+ * subagent's system prompt and tool list.
4
+ *
5
+ * The PROXY owns READER_SYSTEM_PROMPT (cspeach-proxy/src/skills/
6
+ * reader-prompt.ts): for the skill header `_reader` it sends that constant
7
+ * and ignores the client's `system`. This is the CLI's pinned copy, used only
8
+ * where the CLI talks to a model directly (BYOK, AI-hub, local).
9
+ * src/tools/__tests__/reader-prompt-pin.test.ts asserts the two template
10
+ * literals stay byte-equal — edit both files together.
11
+ *
12
+ * No imports on purpose: tool-dispatch.ts reads READER_TOOLS, and importing
13
+ * read_agent.ts there would close a cycle through agent/loop.ts.
14
+ */
15
+ /** The skill header a reader's model calls carry. */
16
+ export const READER_SKILL = '_reader';
17
+ export const READER_SYSTEM_PROMPT = `You are a read-only investigator inside CSPeach. Read exactly what the brief asks for with the tools you have, then answer the brief. Report facts with object names, includes and line numbers. Say what you could not find. No recommendations unless the brief asks. Stay under the report limit.`;
18
+ /**
19
+ * The reader's fixed, small, static tool list (no deferred entries, no tool
20
+ * search): the ten read-only tools of spec D18 plus tool_output_read (Task
21
+ * 17), so a reader can open a result that was saved to a file. Every entry is
22
+ * `isMutating: false`. Flag-gated entries (file_read, grep, glob) reach the
23
+ * reader only when the session has them on, and standalone hides the SAP ones.
24
+ * tool-dispatch.ts refuses every other tool while `ctx.readerMode` is set.
25
+ */
26
+ export const READER_TOOLS = Object.freeze([
27
+ 'sap_get_source',
28
+ 'sap_object_structure',
29
+ 'sap_search_object',
30
+ 'sap_sql_query',
31
+ 'sap_usage_references',
32
+ 'sap_class_includes',
33
+ 'sap_atc_run',
34
+ 'file_read',
35
+ 'grep',
36
+ 'glob',
37
+ 'tool_output_read',
38
+ ]);
39
+ /** Task 18 fix I1 — the result of a reader stopped by Esc / Ctrl+C (or never started). */
40
+ export const INTERRUPTED_LINE = 'interrupted: the reader was stopped before it finished';
41
+ /**
42
+ * Task 18 fix M5 — true when a reader would have at least one real read tool
43
+ * among `visibleNames` (tool_output_read alone reads nothing new: standalone
44
+ * with the file tools off). The loop hides read_agent otherwise.
45
+ */
46
+ export function hasReaderReadTools(visibleNames) {
47
+ return READER_TOOLS.some((n) => n !== 'tool_output_read' && visibleNames.has(n));
48
+ }
@@ -124,7 +124,9 @@ registerTool({
124
124
  'in_progress at a time. The user sees this list — keep items short and outcome-shaped. ' +
125
125
  'Update at EVERY step transition: when you finish a step, immediately call todo_set ' +
126
126
  'marking it completed and the next step in_progress. Never batch several finished steps ' +
127
- 'into one later call — the user watches the ▶ indicator live.',
127
+ 'into one later call — the user watches the ▶ indicator live. ' +
128
+ // Task 16 (cost profile lever 7): a lone todo_set round re-reads the whole context.
129
+ 'Call todo_set in the same response as your next action, never as the only tool call in a response.',
128
130
  isMutating: false,
129
131
  category: 'session',
130
132
  input_schema: {
@@ -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('');