@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,75 @@
1
+ /**
2
+ * CLI copy of cspeach-proxy/src/skills/preamble.ts FORGE_PRINCIPLES_PREAMBLE.
3
+ *
4
+ * Task 5 (2026-09-27, ruling F3): BYOK, AI-hub and local build the system
5
+ * prompt client-side, so they need the same preamble the proxy prepends for
6
+ * managed turns. src/skills/__tests__/preamble-pin.test.ts asserts the two
7
+ * template literals stay byte-equal — edit both files together.
8
+ */
9
+ export const FORGE_PRINCIPLES_PREAMBLE = `# CSPeach Principles
10
+
11
+ You are operating inside CSPeach, an AI-assisted ABAP development tool with
12
+ direct access to the user's SAP system via ADT. Follow these principles on
13
+ every turn. The active skill may specialize your behavior; these principles
14
+ take precedence if a skill's instruction conflicts with them.
15
+
16
+ ## Correctness over speed
17
+ - Read before writing. Use sap_get_source and sap_object_structure to ground
18
+ your understanding in the real code before proposing changes.
19
+ - Never hallucinate method names, field names, parameter types, or table
20
+ structures. If you're not sure, look it up.
21
+ - When modifying method logic without changing the class definition
22
+ (no new/removed methods, no signature changes), use sap_update_method
23
+ per method. Do NOT rewrite the full class with sap_set_source.
24
+ - Batch independent reads in one response.
25
+ - When a result says it was saved to a file, read the slice you need with
26
+ tool_output_read(path, offset, limit); do not re-run the command.
27
+
28
+ ## Heavy reading
29
+ - Send more than two source reads, any where-used scan, ATC-finding triage or
30
+ a package inventory to a reader: call read_agent with a precise brief (what
31
+ to read, what to report) and work from its report. Do not read those sources
32
+ yourself when the turn will keep working afterwards.
33
+ - Do not use a reader for one or two reads, a small edit, or to check your own
34
+ work. Never more than three readers for one task unless the user asks. Send
35
+ independent readers in one response so they run at the same time.
36
+
37
+ ## Safety gates are mandatory
38
+ - Mutating tools (sap_set_source, sap_update_method, sap_delete_object,
39
+ sap_activate, sap_create_object, sap_transport_*, sap_message_maintain,
40
+ sap_number_range_intervals, sap_service_binding_publish) require a valid
41
+ approval_id from request_approval. You cannot bypass this.
42
+ - Snapshots are taken automatically before every write — do not ask the
43
+ user to confirm snapshots.
44
+ - After every write, syntax is checked and activation is verified. You
45
+ will see results as tool_results. Do not activate if syntax errors exist.
46
+
47
+ ## Plan before batch
48
+ - If you will modify more than one object, call request_approval with all
49
+ changes in a single changes[] array first. The user sees a holistic plan
50
+ before per-change prompts.
51
+ - If a plan is rejected, ask the user what to change. Do not silently
52
+ revise and re-submit.
53
+
54
+ ## Transport discipline
55
+ - All writes go into the active session transport. Do not ask the user
56
+ for a transport number per change.
57
+ - Never use \$TMP unless the user explicitly requests local-only work.
58
+
59
+ ## Escalate, don't fail silently
60
+ - If a tool returns an error you don't know how to recover from, surface
61
+ the error to the user with a clear explanation and ask how to proceed.
62
+ - If ATC or syntax errors block activation, fix them or ask — never
63
+ suppress or hide.
64
+ - Stop and ask the user before a 4th attempt on the same operation. Do not keep retrying silently.
65
+
66
+ ## Ask when unsure
67
+ - For ambiguous requests, ask one clarifying question before acting.
68
+ - For destructive operations on production-namespaced objects, confirm
69
+ intent in text even if approval_id is granted.
70
+
71
+ ## One skill per turn
72
+ - If the user's request is better served by a different skill, call
73
+ switch_skill with a clear reason. Do not try to do the work of another
74
+ skill yourself.
75
+ `;
@@ -2,6 +2,11 @@ import { ManifestClient } from './manifest-client.js';
2
2
  export function createManifestSkillSource(manifestUrl) {
3
3
  const client = new ManifestClient(manifestUrl);
4
4
  let cachedManifest = null;
5
+ // Task 5 (2026-09-27) — BYOK / AI-hub fetch the skill body on every model
6
+ // call (the system prompt is built client-side). Cache the sha-verified body
7
+ // for the source's lifetime, like the manifest, so each tool-loop round does
8
+ // not hit the network. Failures are not cached.
9
+ const bodyCache = new Map();
5
10
  async function getManifest() {
6
11
  if (!cachedManifest)
7
12
  cachedManifest = await client.fetchAndVerifyManifest();
@@ -13,7 +18,12 @@ export function createManifestSkillSource(manifestUrl) {
13
18
  const entry = m.skills.find(s => s.name === name);
14
19
  if (!entry)
15
20
  throw new Error(`skill '${name}' not found in manifest (available: ${m.skills.map(s => s.name).join(', ')})`);
16
- return client.fetchSkillBody(name, entry.body_url, { expectedSha256: entry.sha256 });
21
+ const cached = bodyCache.get(name);
22
+ if (cached && cached.sha256 === entry.sha256)
23
+ return cached;
24
+ const body = await client.fetchSkillBody(name, entry.body_url, { expectedSha256: entry.sha256 });
25
+ bodyCache.set(name, body);
26
+ return body;
17
27
  },
18
28
  async listAvailableSkills() {
19
29
  const m = await getManifest();
@@ -11,6 +11,8 @@
11
11
  import { promises as fs } from 'node:fs';
12
12
  import { registerTool } from '../index.js';
13
13
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath } from '../_filesystem-shared.js';
14
+ import { spillToolResult, isUnderSpillRoot } from '../result-spill.js';
15
+ import { basename } from 'node:path';
14
16
  const MAX_LINES = 2000;
15
17
  export async function fileReadHandler(args, ctx) {
16
18
  // Validate offset and limit BEFORE clamping
@@ -68,7 +70,15 @@ export async function fileReadHandler(args, ctx) {
68
70
  const trailer = truncated
69
71
  ? `\n... (truncated, ${allLines.length - (offset + window.length)} more lines — re-read with offset=${offset + window.length})`
70
72
  : '';
71
- return { content: numbered + trailer };
73
+ // Task 17 (spec D12) — a window above 8 000 chars is saved to the session
74
+ // spill file and previewed; today's trailer stays last, byte-identical. A
75
+ // file already under the spill root is a slice already: never re-spilled.
76
+ // The spill keeps the FILE's line numbers (a `.n.txt` spill, which
77
+ // tool_output_read shows unnumbered-again) — one numbering space.
78
+ const body = isUnderSpillRoot(realAbs)
79
+ ? numbered
80
+ : spillToolResult({ content: numbered }, ctx, 'file', { numbered: true, label: basename(realAbs) }).content;
81
+ return { content: body + trailer };
72
82
  }
73
83
  registerTool({
74
84
  name: 'file_read',
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Task 17 (model control, 2026-09-27, spec D12) — large tool results go to a
3
+ * file; the history keeps a preview and a path.
4
+ *
5
+ * Claude Code's "output too large, saved to file" pattern. Above
6
+ * SPILL_THRESHOLD_CHARS the full text is written to
7
+ * ~/.cspeach/streams/<sessionId>/tool-<toolUseId>.txt and the tool_result
8
+ * carries the first 60 lines (plus the last 20 for source, SQL, shell and file windows),
9
+ * then one pointer line naming tool_output_read.
10
+ *
11
+ * Sizing happens ONCE, when the tool_result is created (the handler's return
12
+ * value) — never afterwards, so it is not a history edit and the cached
13
+ * prefix / preserved thinking stay append-only.
14
+ *
15
+ * The spill file holds exactly the text the tool would have returned — no
16
+ * more (no secrets beyond what the tool already produced), and it lives in the
17
+ * per-session area under ~/.cspeach, never in the user's project.
18
+ */
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import * as crypto from 'node:crypto';
22
+ import { streamsRoot } from '../agent/turn-stream.js';
23
+ import { rememberSpillLabel } from '../lib/spill-labels.js';
24
+ export const SPILL_THRESHOLD_CHARS = 8_000;
25
+ export const PREVIEW_HEAD_LINES = 60;
26
+ export const PREVIEW_TAIL_LINES = 20;
27
+ /** One preview line never carries more than this (a minified JSON line must not defeat the preview). */
28
+ export const PREVIEW_LINE_MAX_CHARS = 500;
29
+ /** Character budgets for the head and tail blocks of the preview. */
30
+ export const PREVIEW_HEAD_BUDGET_CHARS = 6_000;
31
+ export const PREVIEW_TAIL_BUDGET_CHARS = 2_000;
32
+ /**
33
+ * Kinds whose preview also keeps the last lines (the end matters: totals,
34
+ * exit code, ENDCLASS; for file_read the window's last line). Only search
35
+ * hits and reports are head-only.
36
+ */
37
+ const TAIL_KINDS = new Set(['source', 'sql', 'shell', 'file']);
38
+ /** Session ids / tool_use ids become file names: keep them path-inert. */
39
+ function safeSegment(s) {
40
+ const cleaned = s.replace(/[^A-Za-z0-9_-]/g, '_');
41
+ return cleaned.length > 0 ? cleaned : '_';
42
+ }
43
+ /** The directory that holds one session's spill files. */
44
+ export function spillDirFor(sessionId) {
45
+ return path.join(streamsRoot(), safeSegment(sessionId));
46
+ }
47
+ /**
48
+ * Session id as the tools see it, or undefined when the ctx carries none
49
+ * (some tests and non-loop call paths). Without a session there is no
50
+ * private spill dir, so nothing is spilled and tool_output_read refuses —
51
+ * never a shared fallback directory (fix round 1, minor 3).
52
+ */
53
+ export function sessionIdOf(ctx) {
54
+ const id = ctx.session?.id;
55
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
56
+ }
57
+ /**
58
+ * Spill file names: `tool-<id>.txt`, or `tool-<id>.n.txt` for text that is
59
+ * already line-numbered (file_read windows keep the FILE's line numbers, so
60
+ * tool_output_read shows those lines as they are instead of numbering again —
61
+ * one numbering space). tool_output_read accepts only these shapes.
62
+ */
63
+ export const SPILL_FILE_RE = /^tool-[A-Za-z0-9_-]+(\.n)?\.txt$/;
64
+ export const NUMBERED_SPILL_RE = /\.n\.txt$/;
65
+ /** Spill files older than this are deleted at REPL start (like the notice logs). */
66
+ export const SPILL_MAX_AGE_DAYS = 14;
67
+ function clipLine(line) {
68
+ if (line.length <= PREVIEW_LINE_MAX_CHARS)
69
+ return line;
70
+ return `${line.slice(0, PREVIEW_LINE_MAX_CHARS)}… [+${line.length - PREVIEW_LINE_MAX_CHARS} chars]`;
71
+ }
72
+ export function pointerLine(lines, chars, filePath) {
73
+ return `[${lines} lines / ${chars} chars — full output saved to ${filePath}; read slices with tool_output_read(path, offset, limit)]`;
74
+ }
75
+ /**
76
+ * Build the preview text for `text` (already known to be large). Pure. Line
77
+ * numbers in the omitted marker are the spill file's own (1-based, =
78
+ * tool_output_read offset + 1); for an already-numbered spill the marker names
79
+ * the offset instead, since the visible numbers belong to the source file.
80
+ */
81
+ export function buildPreview(text, kind, filePath, opts = {}) {
82
+ const lines = text.split('\n');
83
+ const total = lines.length;
84
+ const head = [];
85
+ let used = 0;
86
+ for (let i = 0; i < total && head.length < PREVIEW_HEAD_LINES; i++) {
87
+ const l = clipLine(lines[i]);
88
+ if (head.length > 0 && used + l.length + 1 > PREVIEW_HEAD_BUDGET_CHARS)
89
+ break;
90
+ head.push(l);
91
+ used += l.length + 1;
92
+ }
93
+ const tail = [];
94
+ if (TAIL_KINDS.has(kind)) {
95
+ let tailUsed = 0;
96
+ for (let i = total - 1; i >= head.length && tail.length < PREVIEW_TAIL_LINES; i--) {
97
+ const l = clipLine(lines[i]);
98
+ if (tail.length > 0 && tailUsed + l.length + 1 > PREVIEW_TAIL_BUDGET_CHARS)
99
+ break;
100
+ tail.unshift(l);
101
+ tailUsed += l.length + 1;
102
+ }
103
+ }
104
+ const out = [...head];
105
+ const omittedFrom = head.length + 1; // 1-based first omitted line
106
+ const omittedTo = total - tail.length; // 1-based last omitted line
107
+ if (omittedTo >= omittedFrom) {
108
+ out.push(opts.numbered
109
+ ? `[… ${omittedTo - omittedFrom + 1} lines omitted — tool_output_read offset=${omittedFrom - 1} …]`
110
+ : `[… lines ${omittedFrom}–${omittedTo} omitted …]`);
111
+ }
112
+ out.push(...tail);
113
+ out.push(pointerLine(total, text.length, filePath));
114
+ return out.join('\n');
115
+ }
116
+ /**
117
+ * Size a tool result at ingestion. At or below the threshold → unchanged.
118
+ * Above it → the full text goes to the session spill file and a preview comes
119
+ * back. A write failure fails OPEN (full text returned, nothing lost).
120
+ *
121
+ * `header` (sap_get_source's version line) counts toward the threshold and
122
+ * leads the preview, but is NOT written to the file: the file is the body
123
+ * alone, so tool_output_read line k is body line k (fix round 1, I1), and the
124
+ * pointer's counts describe the body.
125
+ */
126
+ export function spillIfLarge(i) {
127
+ const whole = i.header !== undefined ? `${i.header}\n${i.text}` : i.text;
128
+ if (whole.length <= (i.threshold ?? SPILL_THRESHOLD_CHARS))
129
+ return { spilled: false, text: whole };
130
+ const dir = spillDirFor(i.sessionId);
131
+ const id = safeSegment(i.toolUseId && i.toolUseId.length > 0 ? i.toolUseId : crypto.randomUUID());
132
+ const filePath = path.join(dir, `tool-${id}${i.numbered ? '.n' : ''}.txt`);
133
+ try {
134
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
135
+ fs.writeFileSync(filePath, i.text, { encoding: 'utf-8', mode: 0o600 });
136
+ }
137
+ catch {
138
+ return { spilled: false, text: whole };
139
+ }
140
+ const preview = buildPreview(i.text, i.kind, filePath, { numbered: i.numbered });
141
+ return { spilled: true, text: i.header !== undefined ? `${i.header}\n${preview}` : preview, path: filePath };
142
+ }
143
+ /**
144
+ * Convenience for handlers: size `result.content` with the ctx's session and
145
+ * tool_use id. With a header, `result.content` is the body and the returned
146
+ * content is `header\n…`. No session id → never spilled.
147
+ */
148
+ export function spillToolResult(result, ctx, kind, opts = {}) {
149
+ const sessionId = sessionIdOf(ctx);
150
+ const whole = opts.header !== undefined ? `${opts.header}\n${result.content}` : result.content;
151
+ if (sessionId === undefined)
152
+ return whole === result.content ? result : { ...result, content: whole };
153
+ const { label, ...spillOpts } = opts;
154
+ const s = spillIfLarge({ text: result.content, sessionId, toolUseId: ctx.toolUseId, kind, ...spillOpts });
155
+ if (s.spilled && s.path && label)
156
+ rememberSpillLabel(s.path, label);
157
+ return s.text === result.content ? result : { ...result, content: s.text };
158
+ }
159
+ /**
160
+ * Delete spill files (tool-*.txt in every session dir under the spill root)
161
+ * last modified more than SPILL_MAX_AGE_DAYS ago, then any session dir left
162
+ * empty. Other files (the turn mirrors) are left alone; symlinks and
163
+ * junctions (root, dirs, files) are never followed. Best effort: never
164
+ * throws. Returns how many files were deleted.
165
+ */
166
+ export function pruneSpillFiles(now = Date.now()) {
167
+ const cutoff = now - SPILL_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
168
+ let removed = 0;
169
+ let dirs;
170
+ try {
171
+ // Final review tools-I1 — never follow links: a symlinked / junctioned
172
+ // root, session dir or file is skipped (lstat), so the prune can never
173
+ // delete outside the CLI's own area.
174
+ if (!fs.lstatSync(streamsRoot()).isDirectory())
175
+ return 0;
176
+ dirs = fs.readdirSync(streamsRoot());
177
+ }
178
+ catch {
179
+ return 0;
180
+ }
181
+ for (const d of dirs) {
182
+ const dir = path.join(streamsRoot(), d);
183
+ let names;
184
+ try {
185
+ const st = fs.lstatSync(dir);
186
+ if (st.isSymbolicLink() || !st.isDirectory())
187
+ continue;
188
+ names = fs.readdirSync(dir);
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ for (const name of names) {
194
+ if (!SPILL_FILE_RE.test(name))
195
+ continue;
196
+ const p = path.join(dir, name);
197
+ try {
198
+ const st = fs.lstatSync(p);
199
+ if (st.isSymbolicLink() || !st.isFile())
200
+ continue;
201
+ if (st.mtimeMs < cutoff) {
202
+ fs.unlinkSync(p);
203
+ removed++;
204
+ }
205
+ }
206
+ catch {
207
+ /* best effort */
208
+ }
209
+ }
210
+ try {
211
+ if (fs.readdirSync(dir).length === 0)
212
+ fs.rmdirSync(dir);
213
+ }
214
+ catch {
215
+ /* best effort */
216
+ }
217
+ }
218
+ return removed;
219
+ }
220
+ /** True when `child` lies strictly inside `dir`. */
221
+ export function isInside(dir, child) {
222
+ const rel = path.relative(dir, child);
223
+ return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel);
224
+ }
225
+ /** `p` and, when it exists, its realpath (8.3 names, /var → /private/var). */
226
+ export function withRealpath(p) {
227
+ try {
228
+ const real = fs.realpathSync.native(p);
229
+ return real === p ? [p] : [p, real];
230
+ }
231
+ catch {
232
+ return [p];
233
+ }
234
+ }
235
+ /** True when `absPath` (already a realpath) lies under the spill root (any session). */
236
+ export function isUnderSpillRoot(absPath) {
237
+ return withRealpath(streamsRoot()).some((root) => isInside(root, absPath));
238
+ }
@@ -24,6 +24,48 @@
24
24
  import { registerTool } from './index.js';
25
25
  import { normaliseSyntaxResult, checkSyntax } from './verify.js';
26
26
  import { recordSyntaxCheck } from './syntax-state.js';
27
+ import { spillToolResult } from './result-spill.js';
28
+ // -----------------------------------------------------------------------
29
+ // Task 17 (spec D12, ruling F18) — model-facing formats.
30
+ // -----------------------------------------------------------------------
31
+ /**
32
+ * sap_get_source's one-line header: `[version: active]`,
33
+ * `[version: inactive | <warning>]` (any version may carry the split-state
34
+ * warning), or `[version: unknown]`. Plan-audit evidence reads the first
35
+ * 200 chars of the result, so the version leads it.
36
+ */
37
+ export function formatSourceHeader(version, warning) {
38
+ return warning ? `[version: ${version} | ${warning.replace(/\s*\n\s*/g, ' ')}]` : `[version: ${version}]`;
39
+ }
40
+ /** Inverse of formatSourceHeader: the version (and warning) back from a result. */
41
+ export function parseSourceHeader(text) {
42
+ const first = text.split('\n', 1)[0] ?? '';
43
+ const m = /^\[version: ([a-z]+)(?: \| (.*))?\]$/.exec(first);
44
+ if (!m)
45
+ return { version: 'unknown' };
46
+ return m[2] !== undefined ? { version: m[1], warning: m[2] } : { version: m[1] };
47
+ }
48
+ /** One TSV cell: tabs / newlines inside a value cannot break the grid. */
49
+ const tsvCell = (v) => (v === null || v === undefined ? '' : String(v)).replace(/[\t\r\n]+/g, ' ');
50
+ export function formatSqlTsv(result) {
51
+ const cols = result.columns ?? [];
52
+ const rows = result.rows ?? [];
53
+ const lines = [cols.map(tsvCell).join('\t'), ...rows.map((r) => cols.map((c) => tsvCell(r[c])).join('\t'))];
54
+ const n = rows.length;
55
+ const total = typeof result.totalRows === 'number' && result.totalRows > n ? ` of ${result.totalRows}` : '';
56
+ lines.push(`(${n} ${n === 1 ? 'row' : 'rows'}${total})`);
57
+ return lines.join('\n');
58
+ }
59
+ /** Search hits, one per line: NAME TYPE PACKAGE DESCRIPTION URI ("-" for an empty field). */
60
+ export function formatSearchHits(hits) {
61
+ if (hits.length === 0)
62
+ return '(no hits)';
63
+ const f = (v) => {
64
+ const t = typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '';
65
+ return t.length > 0 ? t : '-';
66
+ };
67
+ return hits.map((h) => [h.name, h.type, h.packageName ?? h.package, h.description, h.uri].map(f).join(' ')).join('\n');
68
+ }
27
69
  // -----------------------------------------------------------------------
28
70
  // 1. sap_get_source
29
71
  // -----------------------------------------------------------------------
@@ -36,7 +78,8 @@ registerTool({
36
78
  + 'the response is the active source AND a warning is emitted so the '
37
79
  + 'caller knows the inactive edits are not visible. For a freshly '
38
80
  + 'created object with NO active version yet, the inactive (working) '
39
- + 'source is returned with version="inactive" and an honest note.',
81
+ + 'source is returned with version="inactive" and an honest note. '
82
+ + 'The first line is a [version: …] header, not part of the source.',
40
83
  isMutating: false,
41
84
  requiresSap: true,
42
85
  input_schema: {
@@ -117,13 +160,13 @@ registerTool({
117
160
  // probe failed — leave resolvedVersion as 'unknown'
118
161
  }
119
162
  }
120
- return {
121
- content: JSON.stringify({
122
- source,
123
- version: resolvedVersion,
124
- ...(splitStateWarning ? { split_state_warning: splitStateWarning } : {}),
125
- }),
126
- };
163
+ // Fix round 1 (I1): the header leads the result and the preview, but a
164
+ // spill file holds the source alone — tool_output_read line k is source
165
+ // line k, matching syntax-check / ATC line numbers.
166
+ return spillToolResult({ content: String(source) }, ctx, 'source', {
167
+ header: formatSourceHeader(resolvedVersion, splitStateWarning),
168
+ label: String(args.name).toUpperCase(),
169
+ });
127
170
  },
128
171
  });
129
172
  // -----------------------------------------------------------------------
@@ -155,10 +198,11 @@ registerTool({
155
198
  // `version` right after name/type puts it inside the excerpt window.
156
199
  const versionMatch = /\badtcore:version="([^"]*)"/.exec(result.rawXml ?? '');
157
200
  const version = versionMatch?.[1] || 'unknown';
158
- // name/type/version lead the serialized JSON (rawXml stays last via the
159
- // rest spread), so `version` sits inside the excerpt window while every
160
- // ObjectStructure field is preserved.
161
- const { name, type, ...rest } = result;
201
+ // name/type/version lead the serialized JSON, so `version` sits inside
202
+ // the excerpt window. Task 17: rawXml is parsed above and never sent to
203
+ // the model (it was the bulk of the result).
204
+ const { name, type, rawXml, ...rest } = result;
205
+ void rawXml;
162
206
  return { content: JSON.stringify({ name, type, version, ...rest }, null, 2) };
163
207
  },
164
208
  });
@@ -187,7 +231,7 @@ registerTool({
187
231
  },
188
232
  handler: async (args, ctx) => {
189
233
  const results = await ctx.adt.searchObject(args.query, args.type, args.maxResults ?? 100);
190
- return { content: JSON.stringify(results, null, 2) };
234
+ return spillToolResult({ content: formatSearchHits(results) }, ctx, 'search', { label: `search "${args.query}"` });
191
235
  },
192
236
  });
193
237
  // -----------------------------------------------------------------------
@@ -231,7 +275,7 @@ registerTool({
231
275
  },
232
276
  handler: async (args, ctx) => {
233
277
  const result = await ctx.adt.sqlQuery(args.query, args.maxRows ?? 100);
234
- return { content: JSON.stringify(result, null, 2) };
278
+ return spillToolResult({ content: formatSqlTsv(result) }, ctx, 'sql', { label: 'query result' });
235
279
  },
236
280
  });
237
281
  // -----------------------------------------------------------------------
@@ -46,12 +46,21 @@ import { registerTool } from '../index.js';
46
46
  import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
47
47
  import { loadSafelist, buildSafeEnv, resolveExecutable, validateArgv, rejectPathSeparator, DEFAULT_SAFELIST, KILL_GRACE_MS, } from '../_command-shared.js';
48
48
  import { killProcessTree } from '../fiori/preview/registry.js';
49
+ import { spillToolResult } from '../result-spill.js';
49
50
  const DEFAULT_TIMEOUT_MS = 30_000;
50
51
  /** After the kill's grace: how long to wait for the pipes before returning anyway. */
51
52
  const ORPHAN_WAIT_MS = 3_000;
52
53
  const MAX_TIMEOUT_MS = 300_000;
53
54
  const MAX_OUTPUT_BYTES = 1_000_000; // per stream
55
+ /**
56
+ * Task 17 (spec D12) — output above 8 000 chars is saved to the session spill
57
+ * file; the result carries the first 60 + last 20 lines (the exit code stays
58
+ * visible) and a pointer. Sized once, here, when the result is created.
59
+ */
54
60
  export async function shellExecHandler(args, ctx) {
61
+ return spillToolResult(await runShellExec(args, ctx), ctx, 'shell', { label: 'command output' });
62
+ }
63
+ async function runShellExec(args, ctx) {
55
64
  if (!args.command || typeof args.command !== 'string') {
56
65
  return { content: 'error: command is required (non-empty string)', is_error: true };
57
66
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Task 18 (ruling F21) — parallel readers share one AdtClient, and concurrent
3
+ * ADT requests through it are unverified. Readers run their MODEL work in
4
+ * parallel, but their SAP calls go through one lock: one ADT request at a
5
+ * time for the whole reader group.
6
+ */
7
+ /** A FIFO mutex. A call that fails releases the lock like one that succeeds. */
8
+ export function createAdtLock() {
9
+ let tail = Promise.resolve();
10
+ return {
11
+ run(fn) {
12
+ const result = tail.then(() => fn());
13
+ tail = result.then(() => undefined, () => undefined);
14
+ return result;
15
+ },
16
+ };
17
+ }
18
+ /**
19
+ * A view of `adt` whose method calls run through `lock`. Methods run on the
20
+ * real client (`this` is the client, so its internal calls are not re-queued);
21
+ * non-function properties pass through. Every AdtClient method a reader tool
22
+ * calls is async, so returning a promise changes nothing for the caller.
23
+ */
24
+ export function serializeAdt(adt, lock) {
25
+ return new Proxy(adt, {
26
+ get(target, prop, _receiver) {
27
+ const value = Reflect.get(target, prop, target);
28
+ if (typeof value !== 'function')
29
+ return value;
30
+ return (...args) => lock.run(() => value.apply(target, args));
31
+ },
32
+ });
33
+ }
@@ -122,6 +122,8 @@ export async function agentRunHandler(args, ctx) {
122
122
  cwd: ctx.cwd,
123
123
  provider: ctx.provider,
124
124
  skillSource: ctx.skillSource,
125
+ // Task 18 fix M4 — an agent_run child never starts reader subagents.
126
+ subagent: true,
125
127
  // previewHook + pendingDispatch + currentTransport + todoEmitter
126
128
  // intentionally omitted.
127
129
  };