@cspeach/cli 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/dist/agent/intent-system-prompt.js +46 -0
  2. package/dist/agent/loop.js +180 -18
  3. package/dist/agent/parallel-write-guard.js +71 -0
  4. package/dist/agent/repair-partial.js +88 -1
  5. package/dist/agent/retry-cap.js +121 -0
  6. package/dist/agent/summarise-via-provider.js +51 -0
  7. package/dist/approvals/jwt.js +33 -12
  8. package/dist/commands/auto-compact.js +94 -0
  9. package/dist/commands/compact.js +265 -0
  10. package/dist/commands/cost.js +56 -0
  11. package/dist/commands/help.js +21 -14
  12. package/dist/config/loader.js +39 -0
  13. package/dist/cost/cost-log.js +113 -0
  14. package/dist/cost/pricing.js +91 -0
  15. package/dist/index.js +0 -0
  16. package/dist/one-shot.js +1 -1
  17. package/dist/renderer/markdown.js +7 -1
  18. package/dist/renderer/status-footer.js +37 -14
  19. package/dist/renderer/thinking-heartbeat.js +15 -3
  20. package/dist/renderer/tool-widget.js +6 -1
  21. package/dist/renderer/tty.js +27 -0
  22. package/dist/repl/cspeach-shell-detect.js +33 -0
  23. package/dist/repl/post-turn-status.js +68 -0
  24. package/dist/repl/slash-completer.js +2 -0
  25. package/dist/repl/slash-picker.js +2 -0
  26. package/dist/repl.js +371 -46
  27. package/dist/sap-errors/parse-adt-exception.js +149 -0
  28. package/dist/session/schema.js +2 -1
  29. package/dist/session/store.js +19 -0
  30. package/dist/tools/_filesystem-shared.js +9 -1
  31. package/dist/tools/ask-question.js +19 -0
  32. package/dist/tools/sap-read.js +56 -4
  33. package/dist/tools/sap-write.js +19 -0
  34. package/dist/tools/subagent/schedule_draft_create.js +137 -0
  35. package/dist/ui/alt-screen.js +120 -0
  36. package/dist/ui/app.js +39 -15
  37. package/dist/ui/ask-question-emitter.js +13 -0
  38. package/dist/ui/body.js +39 -23
  39. package/dist/ui/coaching-picker-classic.js +8 -1
  40. package/dist/ui/footer.js +6 -1
  41. package/dist/ui/header.js +19 -4
  42. package/dist/ui/sap-state-store.js +13 -1
  43. package/dist/ui/sidebar.js +5 -3
  44. package/dist/ui/status-emitter.js +19 -0
  45. package/dist/ui/status-row.js +30 -4
  46. package/dist/ui/widgets/ask-question-modal.js +132 -0
  47. package/dist/ui/widgets/coaching-picker.js +55 -8
  48. package/package.json +86 -82
  49. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  50. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  51. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  52. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  53. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  54. package/dist/agent/__tests__/retry-key.test.js +0 -77
  55. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  56. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  57. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  58. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  59. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  60. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  61. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  62. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  63. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  64. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  65. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  66. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  67. package/dist/auth/__tests__/auth-file.test.js +0 -42
  68. package/dist/auth/__tests__/me.test.js +0 -43
  69. package/dist/classifier/__tests__/client.test.js +0 -70
  70. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  71. package/dist/commands/__tests__/login.test.js +0 -152
  72. package/dist/commands/__tests__/logout.test.js +0 -39
  73. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  74. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  75. package/dist/commands/__tests__/whoami.test.js +0 -91
  76. package/dist/config/__tests__/llm-config.test.js +0 -28
  77. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  78. package/dist/config/__tests__/write-mode.test.js +0 -36
  79. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  80. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  81. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  82. package/dist/project-context/__tests__/conventions.test.js +0 -221
  83. package/dist/project-context/__tests__/detect.test.js +0 -177
  84. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  85. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  86. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  87. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  88. package/dist/project-context/__tests__/git.test.js +0 -131
  89. package/dist/project-context/__tests__/index-files.test.js +0 -195
  90. package/dist/project-context/__tests__/index.test.js +0 -133
  91. package/dist/project-context/__tests__/render.test.js +0 -322
  92. package/dist/project-context/__tests__/types.test.js +0 -73
  93. package/dist/projects/__tests__/build.test.js +0 -52
  94. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  95. package/dist/projects/__tests__/email-template.test.js +0 -71
  96. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  97. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  98. package/dist/projects/__tests__/extract-design.test.js +0 -62
  99. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  100. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  101. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  102. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  103. package/dist/projects/__tests__/filename.test.js +0 -32
  104. package/dist/projects/__tests__/promote-command.test.js +0 -170
  105. package/dist/projects/__tests__/promote.test.js +0 -181
  106. package/dist/projects/__tests__/save-command.test.js +0 -216
  107. package/dist/projects/__tests__/save.test.js +0 -41
  108. package/dist/projects/__tests__/status.test.js +0 -208
  109. package/dist/projects/__tests__/types.test.js +0 -41
  110. package/dist/projects/__tests__/validate.test.js +0 -165
  111. package/dist/projects/__tests__/workspace.test.js +0 -337
  112. package/dist/repl/__tests__/current-transport.test.js +0 -43
  113. package/dist/repl/__tests__/diff-display.test.js +0 -59
  114. package/dist/repl/__tests__/file-picker.test.js +0 -97
  115. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  116. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  117. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  118. package/dist/sap/__tests__/system-info.test.js +0 -344
  119. package/dist/session/__tests__/store-discovery.test.js +0 -114
  120. package/dist/session/__tests__/time-ago.test.js +0 -53
  121. package/dist/skills/__tests__/canonical.test.js +0 -20
  122. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  123. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  124. package/dist/skills/__tests__/source-managed.test.js +0 -60
  125. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  126. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  127. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  128. package/dist/tools/__tests__/_flag.test.js +0 -66
  129. package/dist/tools/__tests__/_helpers.js +0 -17
  130. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  131. package/dist/tools/__tests__/agent_run.test.js +0 -166
  132. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  133. package/dist/tools/__tests__/background_run.test.js +0 -113
  134. package/dist/tools/__tests__/convention_get.test.js +0 -53
  135. package/dist/tools/__tests__/file-edit.test.js +0 -72
  136. package/dist/tools/__tests__/file-read.test.js +0 -81
  137. package/dist/tools/__tests__/file-write.test.js +0 -68
  138. package/dist/tools/__tests__/glob.test.js +0 -128
  139. package/dist/tools/__tests__/grep.test.js +0 -71
  140. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  141. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  142. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  143. package/dist/tools/__tests__/registry-category.test.js +0 -60
  144. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  145. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  146. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  147. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  148. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  149. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  150. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  151. package/dist/tools/__tests__/web_search.test.js +0 -215
  152. package/dist/tools/__tests__/write-mode.test.js +0 -32
  153. package/dist/ui/__tests__/login-banner.test.js +0 -69
@@ -0,0 +1,149 @@
1
+ /**
2
+ * parse-adt-exception — extract the human-readable error message out of
3
+ * a SAP ADT exception envelope so the renderer can display the actual
4
+ * cause instead of an XML preamble.
5
+ *
6
+ * Two envelope shapes are recognised, both observed live on S/4HANA 2023:
7
+ *
8
+ * 1. `<exc:exception>` — used for HTTP 4xx on writes (lock conflicts,
9
+ * transport refusals, authorisation failures). Contains a
10
+ * `<message lang="EN">…</message>` text plus optional `<entry>`
11
+ * key/value bag with T100KEY-ID + T100KEY-NO message-class coords.
12
+ *
13
+ * 2. `<chkl:messages>` — used by activator / syntax-check responses
14
+ * that surface errors via `<msg severity="E"><shortText><txt>…</txt>`.
15
+ *
16
+ * Returns the formatted single-line string, or `null` when the input
17
+ * does not look like ADT exception XML (caller falls back to the raw
18
+ * detail string, which is still better than nothing).
19
+ *
20
+ * Background: during the 2026-05-15 live session the renderer printed
21
+ * the JSON-wrapped error verbatim, which slice(0, 80)'d down to the XML
22
+ * preamble (`HTTP 409: <?xml v…`) and hid the actual message. With this
23
+ * helper the same error renders as e.g. `CTS_WBO_API/047: Request
24
+ * S4HK903388 is not a local request`.
25
+ */
26
+ /**
27
+ * Detect-and-extract. Returns null if `raw` doesn't smell like an ADT
28
+ * exception envelope; otherwise a single-line `T100KEY-ID/T100KEY-NO:
29
+ * <message>` (when keys are present) or just `<message>`.
30
+ */
31
+ export function parseAdtException(raw) {
32
+ if (!raw || typeof raw !== 'string')
33
+ return null;
34
+ // Bail fast if the haystack has no XML at all. We do NOT require a
35
+ // <?xml prolog — ADT sometimes embeds the envelope inline in a larger
36
+ // string ("HTTP 409: <exc:exception>…").
37
+ const hasExc = raw.includes('exc:exception') || raw.includes('<exception');
38
+ const hasChkl = /<(?:[\w:]+:)?(?:msg|checkMessage)\b/i.test(raw);
39
+ if (!hasExc && !hasChkl)
40
+ return null;
41
+ // --- Shape 1: exc:exception envelope --------------------------------
42
+ // `<message lang="EN">…</message>` is the canonical user-facing text.
43
+ // The xmlns prefix is optional; some ADT responses drop it.
44
+ const messageMatch = raw.match(/<(?:[\w:]+:)?message\b[^>]*\blang="(?:EN|en)"[^>]*>([\s\S]*?)<\/(?:[\w:]+:)?message>/i) ??
45
+ raw.match(/<(?:[\w:]+:)?message\b[^>]*>([\s\S]*?)<\/(?:[\w:]+:)?message>/i);
46
+ if (messageMatch) {
47
+ const message = collapseWhitespace(decodeEntities(messageMatch[1] ?? '')).trim();
48
+ if (message.length === 0) {
49
+ // Empty <message> is a malformed envelope — fall through to chkl
50
+ // handling below in case both shapes are present.
51
+ }
52
+ else {
53
+ const id = extractEntryValue(raw, 'T100KEY-ID');
54
+ const no = extractEntryValue(raw, 'T100KEY-NO');
55
+ if (id && no)
56
+ return `${id}/${no}: ${message}`;
57
+ return message;
58
+ }
59
+ }
60
+ // --- Shape 2: chkl:messages with <msg severity="E"> -----------------
61
+ // <msg severity="E" ...><shortText><txt>…</txt></shortText></msg>
62
+ // We pick the FIRST error-severity message — these envelopes typically
63
+ // surface one root cause + zero or more follow-ups, and the first is
64
+ // the most actionable for a single-line summary.
65
+ const msgPattern = /<(?:[\w:]+:)?msg\b[^>]*(?:severity|typ|type)="[EeAa]"[^>]*(?:\/>|>([\s\S]*?)<\/(?:[\w:]+:)?msg>)/gi;
66
+ let m;
67
+ while ((m = msgPattern.exec(raw)) !== null) {
68
+ const inner = m[1] ?? '';
69
+ const txt = inner.match(/<(?:[\w:]+:)?txt\b[^>]*>([\s\S]*?)<\/(?:[\w:]+:)?txt>/i)?.[1];
70
+ if (txt) {
71
+ const message = collapseWhitespace(decodeEntities(txt)).trim();
72
+ if (message.length > 0)
73
+ return message;
74
+ }
75
+ // Fallback: <msg ... shortText="…"/> attribute form (older systems).
76
+ const shortAttr = m[0].match(/\bshortText="([^"]+)"/i)?.[1];
77
+ if (shortAttr) {
78
+ const message = collapseWhitespace(decodeEntities(shortAttr)).trim();
79
+ if (message.length > 0)
80
+ return message;
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+ /**
86
+ * Look up an `<entry key="…">value</entry>` pair from the ADT exception
87
+ * envelope's `entry` bag.
88
+ */
89
+ function extractEntryValue(xml, key) {
90
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+ const re = new RegExp(`<(?:[\\w:]+:)?entry\\b[^>]*\\bkey="${escapedKey}"[^>]*>([\\s\\S]*?)<\\/(?:[\\w:]+:)?entry>`, 'i');
92
+ const m = xml.match(re);
93
+ return m ? collapseWhitespace(decodeEntities(m[1] ?? '')).trim() : null;
94
+ }
95
+ /** Minimal XML entity decoder for the five predefined entities. */
96
+ function decodeEntities(s) {
97
+ return s
98
+ .replace(/&lt;/g, '<')
99
+ .replace(/&gt;/g, '>')
100
+ .replace(/&quot;/g, '"')
101
+ .replace(/&apos;/g, "'")
102
+ // &amp; must run LAST so we don't double-decode (&amp;lt; → &lt; → <).
103
+ .replace(/&amp;/g, '&');
104
+ }
105
+ /** Collapse internal whitespace runs so a multi-line message renders on one line. */
106
+ function collapseWhitespace(s) {
107
+ return s.replace(/\s+/g, ' ');
108
+ }
109
+ /**
110
+ * Convenience wrapper for the renderer: given an arbitrary error detail
111
+ * string (which may be raw, JSON-wrapped, or plain text), return a
112
+ * one-line human summary suitable for the tool-result row.
113
+ *
114
+ * Order of operations:
115
+ * 1. If the string parses as JSON with a `detail` or `error` field,
116
+ * operate on that field.
117
+ * 2. If the (possibly unwrapped) text contains an ADT exception
118
+ * envelope, return its parsed message.
119
+ * 3. Otherwise return the (unwrapped) raw text.
120
+ */
121
+ export function formatToolErrorSummary(raw) {
122
+ if (!raw)
123
+ return '';
124
+ const unwrapped = unwrapJsonDetail(raw);
125
+ const adt = parseAdtException(unwrapped);
126
+ if (adt)
127
+ return adt;
128
+ return unwrapped;
129
+ }
130
+ function unwrapJsonDetail(raw) {
131
+ const trimmed = raw.trim();
132
+ if (!trimmed.startsWith('{'))
133
+ return raw;
134
+ try {
135
+ const obj = JSON.parse(trimmed);
136
+ const detail = obj['detail'];
137
+ if (typeof detail === 'string' && detail.length > 0)
138
+ return detail;
139
+ const err = obj['error'];
140
+ if (typeof err === 'string' && err.length > 0) {
141
+ // Combine error+detail when both present, but detail already handled above.
142
+ return err;
143
+ }
144
+ }
145
+ catch {
146
+ /* not JSON; fall through to raw */
147
+ }
148
+ return raw;
149
+ }
@@ -11,10 +11,11 @@ export function newSession(id, sapSystem, skill, model) {
11
11
  model,
12
12
  messages: [],
13
13
  toolCalls: [],
14
- usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 },
14
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
15
15
  last_skill: null,
16
16
  last_object: null,
17
17
  lastUserPrompt: null,
18
18
  awaitingSkillAnswer: false,
19
+ agentMode: 'skill',
19
20
  };
20
21
  }
@@ -43,10 +43,23 @@ function validateSchema(parsed) {
43
43
  if (parsed.schema_version < SCHEMA_VERSION) {
44
44
  throw new Error(`Session schema is older (v${parsed.schema_version}). Auto-migration not yet implemented. Run: cspeach session export ${parsed.id} | wipe`);
45
45
  }
46
+ // CONTRACT: when adding a new SessionState field, EITHER add a back-compat
47
+ // default below OR bump SCHEMA_VERSION + ship a migration. Never both,
48
+ // never neither. Field defaults stack here so loadSession() always returns
49
+ // a fully-typed SessionState regardless of serialisation vintage.
46
50
  // v0.3.1: `lastUserPrompt` added as a required field but older session
47
51
  // files saved at this same SCHEMA_VERSION don't have it. Default
48
52
  // undefined → null so the type contract (string | null) holds.
49
53
  parsed.lastUserPrompt ??= null;
54
+ // Phase 4 chunk 4A: agentMode defaults to 'skill' for sessions written
55
+ // before this chunk landed. Mirrors the lastUserPrompt back-compat above.
56
+ parsed.agentMode ??= 'skill';
57
+ // Phase 1 cost tracking (2026-05-16): cache_creation_input_tokens added
58
+ // to usage. Pre-2026-05-16 sessions don't have it; default to 0 so the
59
+ // cost calculator can safely treat usage as a TokenCounts shape.
60
+ if (parsed.usage) {
61
+ parsed.usage.cache_creation_input_tokens ??= 0;
62
+ }
50
63
  // 2026-05-01: heal sessions corrupted by interrupted streaming. A
51
64
  // tool_use block that still has `partial_json` was mid-stream when the
52
65
  // turn ended; replaying it to the API fails with HTTP 400
@@ -73,6 +86,10 @@ function validateSchema(parsed) {
73
86
  return parsed;
74
87
  }
75
88
  export async function listSessions(limit = 10) {
89
+ // NOTE: bypasses validateSchema() for performance — reads JSON directly via
90
+ // JSON.parse + cast. Any new SessionState field accessed here must be
91
+ // defaulted at the read site (e.g. `?? 'skill'`) because pre-chunk-4A
92
+ // serialised sessions on disk lack the field.
76
93
  const dir = sessionsDir();
77
94
  const files = await fs.readdir(dir).catch(() => []);
78
95
  const results = [];
@@ -124,6 +141,8 @@ export async function listSessions(limit = 10) {
124
141
  * a full parse + sort pass. Returns 0 on any IO failure (best-effort discovery).
125
142
  */
126
143
  export async function countInterruptedSessions() {
144
+ // NOTE: bypasses validateSchema() (same as listSessions). Any new
145
+ // SessionState field accessed here must be defaulted at the read site.
127
146
  try {
128
147
  const dir = sessionsDir();
129
148
  const files = await fs.readdir(dir).catch(() => []);
@@ -17,8 +17,16 @@ export const BLOCKED_PREFIXES = ['.cspeach', '.env', '.git', '.cspeach-design'];
17
17
  */
18
18
  export function isDenylistedPath(realAbsPath, root) {
19
19
  const relFromRoot = path.relative(path.resolve(root), realAbsPath).replace(/\\/g, '/');
20
+ // Case-insensitive comparison: macOS APFS (default) and Windows NTFS (default)
21
+ // are case-insensitive filesystems, so `.Cspeach/auth.json` resolves to the
22
+ // SAME inode as `.cspeach/auth.json`. A byte-exact denylist match would let
23
+ // the model bypass protection with a one-keystroke case change. The returned
24
+ // `relFromRoot` keeps its ORIGINAL case so callers can log what the model
25
+ // actually requested.
26
+ const relLower = relFromRoot.toLowerCase();
20
27
  for (const prefix of BLOCKED_PREFIXES) {
21
- if (relFromRoot === prefix || relFromRoot.startsWith(prefix + '/')) {
28
+ const prefixLower = prefix.toLowerCase();
29
+ if (relLower === prefixLower || relLower.startsWith(prefixLower + '/')) {
22
30
  return { blocked: true, relFromRoot };
23
31
  }
24
32
  }
@@ -25,6 +25,8 @@ import chalk from 'chalk';
25
25
  import { registerTool } from './index.js';
26
26
  import { input, select, checkbox } from '@inquirer/prompts';
27
27
  import { withInquirer } from '../repl/inquirer-guard.js';
28
+ import { shouldUseInk } from '../renderer/tty.js';
29
+ import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
28
30
  registerTool({
29
31
  name: 'ask_question',
30
32
  description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction.",
@@ -80,6 +82,23 @@ registerTool({
80
82
  // works. select() is unambiguous: ↑↓ navigates, Enter picks. We still
81
83
  // expose a "Type a custom answer" option for the LLM-allows-free-text
82
84
  // case so flexibility isn't lost.
85
+ // Critical #2 (2026-05-17) — under Ink, route to an Ink-native modal
86
+ // via askQuestionEmitter instead of inquirer. Inquirer in Ink mode
87
+ // drew raw to stdout and corrupted the React frame. The modal sits
88
+ // inline between Body and Footer (no overlap), supports text /
89
+ // choice / multi, and returns the answer via the same { answer,
90
+ // cancelled } shape this handler already expects.
91
+ if (shouldUseInk()) {
92
+ const result = await askQuestionEmitter.request({
93
+ id, question, context, kind,
94
+ choices: choices ?? [],
95
+ });
96
+ if (result.cancelled || result.answer === null || result.answer.length === 0) {
97
+ return { content: JSON.stringify({ id, kind, answer: null, cancelled: true }) };
98
+ }
99
+ return { content: JSON.stringify({ id, kind, answer: result.answer }) };
100
+ }
101
+ // Classic mode — inquirer path (unchanged from v0.6).
83
102
  console.log('');
84
103
  console.log(chalk.bold(question));
85
104
  if (context)
@@ -27,20 +27,72 @@ import { registerTool } from './index.js';
27
27
  // -----------------------------------------------------------------------
28
28
  registerTool({
29
29
  name: 'sap_get_source',
30
- description: 'Read the source code of an ABAP object by name and type.',
30
+ description: 'Read the source code of an ABAP object by name and type. '
31
+ + 'Optional `version` selects active or inactive source explicitly; '
32
+ + 'omitting it lets SAP choose (usually active). When the object has '
33
+ + 'pending inactive changes and the caller did not pass `version`, '
34
+ + 'the response is the active source AND a warning is emitted so the '
35
+ + 'caller knows the inactive edits are not visible.',
31
36
  isMutating: false,
32
37
  input_schema: {
33
38
  type: 'object',
34
39
  properties: {
35
40
  name: { type: 'string', description: 'Object name, e.g. ZCL_ORDER_HANDLER' },
36
41
  type: { type: 'string', description: 'Object type token, e.g. CLAS, PROG, INTF, DDLS' },
42
+ version: {
43
+ type: 'string',
44
+ enum: ['active', 'inactive'],
45
+ description: 'Optional. When omitted, SAP returns whichever version it chooses (usually active). '
46
+ + 'Pass "active" for the committed source; pass "inactive" to see in-flight edits.',
47
+ },
37
48
  },
38
49
  required: ['name', 'type'],
39
50
  },
40
51
  handler: async (args, ctx) => {
41
- // AdtClient.getSource takes (type, name) — type first
42
- const source = await ctx.adt.getSource(args.type, args.name);
43
- return { content: source };
52
+ // AdtClient.getSource takes (type, name, version?) — type first
53
+ const requestedVersion = args.version === 'active' || args.version === 'inactive' ? args.version : undefined;
54
+ const source = await ctx.adt.getSource(args.type, args.name, requestedVersion);
55
+ // Bug 12 (2026-05-15): tell the caller which version it actually got.
56
+ // When the caller asked explicitly we know; otherwise we probe
57
+ // inactive-objects to detect split state — and report which version
58
+ // SAP returned ('active' if no inactive entry exists; 'unknown' when
59
+ // we couldn't disambiguate). The probe is best-effort: any failure
60
+ // degrades to version='unknown' and the source still comes back.
61
+ let resolvedVersion = requestedVersion ?? 'unknown';
62
+ let splitStateWarning;
63
+ if (!requestedVersion) {
64
+ try {
65
+ const inactive = await ctx.adt.inactiveObjects();
66
+ const upperName = String(args.name).toUpperCase();
67
+ const upperType = String(args.type).toUpperCase();
68
+ const conflict = inactive.find((o) => o.name.toUpperCase() === upperName &&
69
+ (o.type === '' || o.type.toUpperCase().startsWith(upperType)));
70
+ if (conflict) {
71
+ // SAP defaults to the active version when version is unspecified,
72
+ // but the caller should know an inactive copy exists so they don't
73
+ // base dependent edits on stale committed source.
74
+ resolvedVersion = 'active';
75
+ splitStateWarning =
76
+ `${args.type} ${args.name} has a pending inactive version. `
77
+ + `This response is the ACTIVE source; pending edits are NOT included. `
78
+ + `Pass version="inactive" to read the in-flight version, or have the user `
79
+ + `activate (or discard) the inactive version before basing dependent code on this read.`;
80
+ }
81
+ else {
82
+ resolvedVersion = 'active';
83
+ }
84
+ }
85
+ catch {
86
+ // probe failed — leave resolvedVersion as 'unknown'
87
+ }
88
+ }
89
+ return {
90
+ content: JSON.stringify({
91
+ source,
92
+ version: resolvedVersion,
93
+ ...(splitStateWarning ? { split_state_warning: splitStateWarning } : {}),
94
+ }),
95
+ };
44
96
  },
45
97
  });
46
98
  // -----------------------------------------------------------------------
@@ -255,12 +255,31 @@ registerTool({
255
255
  // updateMethod(className, methodName, methodBody, transport?). Same return
256
256
  // semantics as setSource — RETURNS a WriteResult on HTTP-level failure
257
257
  // rather than throwing. Must check writeStatus / error on the return value.
258
+ //
259
+ // Bug 13 (2026-05-15): AdtClient.updateMethod now throws SapError(409)
260
+ // when the class has pending inactive changes (split state). Surface that
261
+ // as a recoverable `split_state` envelope so the model can suggest the
262
+ // user activate / discard the inactive version before retrying — without
263
+ // silently overwriting the inactive-only declarations.
258
264
  let updateResult;
259
265
  try {
260
266
  updateResult = await ctx.adt.updateMethod(args.class_name, args.method_name, args.method_source, args.transport);
261
267
  }
262
268
  catch (err) {
263
269
  const errStr = String(err);
270
+ const httpStatus = err?.httpStatus;
271
+ if (httpStatus === 409 && /pending inactive/i.test(errStr)) {
272
+ await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
273
+ return {
274
+ content: JSON.stringify({
275
+ error: 'split_state',
276
+ detail: errStr,
277
+ class_name: args.class_name,
278
+ recovery: 'Activate (sap_activate) or discard the inactive version of this class, then retry sap_update_method.',
279
+ }),
280
+ is_error: true,
281
+ };
282
+ }
264
283
  await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
265
284
  return {
266
285
  content: JSON.stringify({ error: 'update_failed', detail: errStr }),
@@ -0,0 +1,137 @@
1
+ // cspeach-cli/src/tools/subagent/schedule_draft_create.ts
2
+ /**
3
+ * schedule_draft_create — Phase 3 / Chunk 3E (renamed in Phase 3F BL-3F-I-05).
4
+ *
5
+ * DRAFT-ONLY: persists a schedule envelope to ~/.cspeach/schedules/<uuid>.json.
6
+ * The runner has NOT shipped — the agent must NOT report this as completed
7
+ * scheduling work; tell the user explicitly that the schedule is persisted
8
+ * but will not fire until a future release ships the runner.
9
+ *
10
+ * Filename is ALWAYS crypto.randomUUID — never derived from user input.
11
+ * Closes the path-injection class of attack on the schedules directory.
12
+ *
13
+ * The name itself signals "no runner yet, this only persists" so neither
14
+ * the schema name nor the description can mislead the agent into claiming
15
+ * scheduling success. A future chunk that ships the actual runner can
16
+ * introduce a new (non-draft) schedule tool, at which point this either
17
+ * coexists with it or becomes a deprecated alias.
18
+ *
19
+ * Flag-gated: invisible to listTools() unless
20
+ * CSPEACH_TOOL_SCHEDULE_DRAFT_CREATE=on. isMutating: true (writes filesystem).
21
+ */
22
+ import { promises as fs } from 'node:fs';
23
+ import * as os from 'node:os';
24
+ import * as path from 'node:path';
25
+ import { randomUUID } from 'node:crypto';
26
+ import { registerTool } from '../index.js';
27
+ const NOT_EXECUTED_NOTE = 'DRAFT — schedule envelope persisted but will NOT fire (runner not ' +
28
+ 'implemented). Do NOT tell the user the job is scheduled; tell them ' +
29
+ 'it is drafted.';
30
+ // Test-overridable. Default to ~/.cspeach/schedules. Set null to reset.
31
+ let schedulesDirOverride = null;
32
+ /** Test-only: redirect the schedules dir so tests don't pollute the real home dir. */
33
+ export function __setSchedulesDirForTests(dir) {
34
+ schedulesDirOverride = dir;
35
+ }
36
+ function getSchedulesDir() {
37
+ return schedulesDirOverride ?? path.join(os.homedir(), '.cspeach', 'schedules');
38
+ }
39
+ // Strict ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS(.sss)?Z
40
+ const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
41
+ export async function scheduleDraftCreateHandler(args, _ctx) {
42
+ if (args.kind !== 'oneoff' && args.kind !== 'cron') {
43
+ return { content: `error: kind must be 'oneoff' or 'cron' — got '${args.kind}'`, is_error: true };
44
+ }
45
+ if (!args.when || typeof args.when !== 'string' || args.when.trim().length === 0) {
46
+ return { content: 'error: when is required (non-empty string)', is_error: true };
47
+ }
48
+ if (!args.command || typeof args.command !== 'string' || args.command.trim().length === 0) {
49
+ return { content: 'error: command is required (non-empty string)', is_error: true };
50
+ }
51
+ if (args.kind === 'oneoff') {
52
+ if (!ISO_PATTERN.test(args.when)) {
53
+ return { content: `error: when must be an ISO-8601 UTC timestamp (e.g. 2099-06-01T10:00:00Z) — got '${args.when}'`, is_error: true };
54
+ }
55
+ const t = Date.parse(args.when);
56
+ if (Number.isNaN(t)) {
57
+ return { content: `error: when is not a parseable ISO timestamp: '${args.when}'`, is_error: true };
58
+ }
59
+ // Belt-and-braces against silent month/day rollover. Date.parse('2099-02-30T00:00:00Z')
60
+ // returns a valid timestamp that round-trips to March 2 — without this check, the
61
+ // envelope persists '2099-02-30' but the runner fires on March 2 (phantom job).
62
+ const roundTrip = new Date(t).toISOString();
63
+ // Normalise both sides: lowercase 'z', strip optional fractional seconds, compare.
64
+ const norm = (s) => s.replace(/\.\d+Z$/, 'Z').toUpperCase();
65
+ if (norm(roundTrip) !== norm(args.when)) {
66
+ return { content: `error: when is not a valid calendar date: '${args.when}' (rolls to ${roundTrip})`, is_error: true };
67
+ }
68
+ if (t <= Date.now()) {
69
+ return { content: `error: when is in the past — schedule must be in the future`, is_error: true };
70
+ }
71
+ }
72
+ else {
73
+ // cron: just check it has 5 whitespace-separated fields.
74
+ const fields = args.when.trim().split(/\s+/);
75
+ if (fields.length !== 5) {
76
+ return { content: `error: cron expression must have 5 fields (min hour dom mon dow) — got ${fields.length}`, is_error: true };
77
+ }
78
+ }
79
+ const dir = getSchedulesDir();
80
+ await fs.mkdir(dir, { recursive: true });
81
+ // Defence-in-depth: resolve the directory and verify it sits inside
82
+ // the cspeach home (or the test override). Refuses if a symlink
83
+ // points the dir at /etc/, /tmp/host-controlled/, etc.
84
+ const realDir = await fs.realpath(dir);
85
+ // In test mode the override dir IS the root (self-containment is trivial —
86
+ // mkdtemp gives us a unique dir that acts as both schedules-dir and its own
87
+ // root). In prod the schedules dir must sit UNDER ~/.cspeach/, so the root
88
+ // is the parent. Asymmetric on purpose.
89
+ const expectedRoot = schedulesDirOverride !== null
90
+ ? path.resolve(schedulesDirOverride)
91
+ : path.join(os.homedir(), '.cspeach');
92
+ const rel = path.relative(expectedRoot, realDir);
93
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
94
+ return { content: `error: schedules directory '${realDir}' resolves outside expected root`, is_error: true };
95
+ }
96
+ const id = randomUUID();
97
+ const envelope = {
98
+ id,
99
+ kind: args.kind,
100
+ when: args.when,
101
+ command: args.command,
102
+ createdAt: new Date().toISOString(),
103
+ note: NOT_EXECUTED_NOTE,
104
+ };
105
+ const target = path.join(realDir, `${id}.json`);
106
+ await fs.writeFile(target, JSON.stringify(envelope, null, 2), { encoding: 'utf-8', mode: 0o600 });
107
+ return {
108
+ content: `schedule persisted (NOT YET EXECUTED — runner ships in a later chunk).\n` +
109
+ `path: ${target}\n` +
110
+ `id: ${id}\n` +
111
+ `kind: ${args.kind}\n` +
112
+ `when: ${args.when}\n` +
113
+ `command: ${args.command}\n` +
114
+ `note: ${NOT_EXECUTED_NOTE}`,
115
+ };
116
+ }
117
+ registerTool({
118
+ name: 'schedule_draft_create',
119
+ description: 'DRAFT-ONLY: persists a schedule envelope. The runner has NOT shipped — the agent ' +
120
+ 'must NOT report this as completed scheduling work; tell the user explicitly that the ' +
121
+ 'schedule is persisted but will not fire until a future release ships the runner. ' +
122
+ 'Persists a oneoff or cron schedule envelope to ~/.cspeach/schedules/<uuid>.json. ' +
123
+ 'Filename is a fresh UUID — never derived from input.',
124
+ isMutating: true,
125
+ category: 'subagent',
126
+ flagGated: true,
127
+ input_schema: {
128
+ type: 'object',
129
+ properties: {
130
+ kind: { type: 'string', enum: ['oneoff', 'cron'], description: 'oneoff = single ISO timestamp; cron = 5-field expression' },
131
+ when: { type: 'string', description: 'ISO-8601 UTC timestamp for oneoff, or 5-field cron expression' },
132
+ command: { type: 'string', description: 'The command the runner WOULD execute (free-form string for now)' },
133
+ },
134
+ required: ['kind', 'when', 'command'],
135
+ },
136
+ handler: (args, ctx) => scheduleDraftCreateHandler(args, ctx),
137
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Alt-screen lifecycle for Ink mode — Phase A* (2026-05-16).
3
+ *
4
+ * Default Ink renders into the regular stdout stream ("inline mode"). It
5
+ * tracks its own lines and tries to redraw in place via cursor-up + clear-
6
+ * line tricks. That works while everything fits on one screen. The moment
7
+ * content exceeds terminal height:
8
+ *
9
+ * - new model output scrolls past
10
+ * - the rendered "sidebar at the top" becomes a fossil
11
+ * - the Footer that should be at the bottom gets shoved off
12
+ * - subsequent renders just append more lines below the fold
13
+ *
14
+ * Fix: switch the terminal to its **alternate screen buffer** (xterm
15
+ * DEC mode 1049). The alt-screen is a separate, non-scrollable buffer
16
+ * where:
17
+ *
18
+ * - the entire screen IS the app
19
+ * - regions (Header / Sidebar / Body / StatusRow / Footer) stay locked
20
+ * - on exit we swap back to the original buffer — shell scrollback is
21
+ * preserved untouched
22
+ *
23
+ * Used by vim, htop, less, lazygit, Claude Code itself.
24
+ *
25
+ * Safety: enter/exit MUST be paired. If a process crashes after entering
26
+ * but before exiting, the user is left in a blank alt-screen until they
27
+ * type `reset`. This module installs SIGINT/SIGTERM/exit handlers to
28
+ * exit alt-screen on any common termination path.
29
+ */
30
+ const ENTER_ALT_SCREEN = '\x1b[?1049h\x1b[H'; // enter alt-screen + cursor home
31
+ const EXIT_ALT_SCREEN = '\x1b[?1049l'; // exit alt-screen (restores prior buffer)
32
+ const HIDE_CURSOR = '\x1b[?25l';
33
+ const SHOW_CURSOR = '\x1b[?25h';
34
+ let active = false;
35
+ let cleanupInstalled = false;
36
+ let cursorHideInterval = null;
37
+ /**
38
+ * Switch the terminal into alt-screen mode. Idempotent — calling twice
39
+ * is a no-op. Installs a one-time exit cleanup so a crash or signal
40
+ * always restores the user's shell.
41
+ *
42
+ * `hideCursor: false` keeps the cursor visible (we want it inside the
43
+ * Footer's TextInput). Pass `true` only for non-interactive renderers.
44
+ */
45
+ export function enterAltScreen(opts) {
46
+ if (active)
47
+ return;
48
+ active = true;
49
+ process.stdout.write(ENTER_ALT_SCREEN);
50
+ if (opts?.hideCursor) {
51
+ process.stdout.write(HIDE_CURSOR);
52
+ // Phase A** (2026-05-16) — Ink internally calls cli-cursor.show() on
53
+ // every render because TextInput needs the cursor. That re-shows the
54
+ // native terminal cursor we just hid → user sees a blinking cursor
55
+ // parked at the bottom-right corner of the alt-screen ("booger").
56
+ // ink-text-input draws its OWN visual cursor (inverse-block char)
57
+ // independent of the native terminal cursor, so we don't actually
58
+ // need it visible. Periodic re-emit of hide-cursor keeps fighting
59
+ // Ink to keep it hidden. 100ms is invisible to the user; the inverse-
60
+ // block visual cursor remains live so there's no UX loss.
61
+ if (cursorHideInterval)
62
+ clearInterval(cursorHideInterval);
63
+ cursorHideInterval = setInterval(() => {
64
+ if (active)
65
+ process.stdout.write(HIDE_CURSOR);
66
+ }, 100);
67
+ cursorHideInterval.unref?.();
68
+ }
69
+ installCleanupOnce();
70
+ }
71
+ /**
72
+ * Restore the original screen buffer. Safe to call multiple times — only
73
+ * the first call has effect. ALSO installed automatically as exit/signal
74
+ * handler so manual calls aren't strictly required if the process exits
75
+ * cleanly. Manual call is preferred when you have a controlled shutdown
76
+ * (e.g. /exit slash command) so the user sees the cursor restoration
77
+ * happen before the shell prompt returns.
78
+ */
79
+ export function leaveAltScreen() {
80
+ if (!active)
81
+ return;
82
+ active = false;
83
+ if (cursorHideInterval) {
84
+ clearInterval(cursorHideInterval);
85
+ cursorHideInterval = null;
86
+ }
87
+ process.stdout.write(SHOW_CURSOR);
88
+ process.stdout.write(EXIT_ALT_SCREEN);
89
+ }
90
+ function installCleanupOnce() {
91
+ if (cleanupInstalled)
92
+ return;
93
+ cleanupInstalled = true;
94
+ // Normal exit path — e.g. process.exit(0) called after donePromise resolves.
95
+ process.on('exit', leaveAltScreen);
96
+ // Signals: SIGINT (Ctrl-C escapes any handlers), SIGTERM (kill),
97
+ // SIGHUP (terminal closed). Always restore the screen, THEN re-raise
98
+ // so the default handler runs (otherwise process hangs).
99
+ const sigHandler = (sig) => {
100
+ leaveAltScreen();
101
+ // Re-raise: detach our handler first so the default takes over.
102
+ process.off(sig, sigHandler);
103
+ process.kill(process.pid, sig);
104
+ };
105
+ process.on('SIGINT', sigHandler);
106
+ process.on('SIGTERM', sigHandler);
107
+ process.on('SIGHUP', sigHandler);
108
+ // Uncaught — last resort. If anything throws after enter, at least
109
+ // give the user back their shell before the stack trace prints.
110
+ process.on('uncaughtException', (err) => {
111
+ leaveAltScreen();
112
+ // Print to stderr AFTER restoring screen, so the message lands in
113
+ // the user's normal shell scrollback rather than the alt-screen.
114
+ process.stderr.write(`\nUncaught exception in CSPeach (Ink mode):\n${err.stack ?? String(err)}\n`);
115
+ process.exit(1);
116
+ });
117
+ }
118
+ /** For tests. */
119
+ export function _isActive() { return active; }
120
+ export function _resetForTests() { active = false; cleanupInstalled = false; }