@cspeach/cli 0.6.5 → 0.7.1
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.
- package/README.md +26 -0
- package/dist/agent/intent-system-prompt.js +46 -0
- package/dist/agent/loop.js +180 -18
- package/dist/agent/parallel-write-guard.js +71 -0
- package/dist/agent/repair-partial.js +88 -1
- package/dist/agent/retry-cap.js +121 -0
- package/dist/agent/summarise-via-provider.js +51 -0
- package/dist/approvals/jwt.js +33 -12
- package/dist/commands/auto-compact.js +94 -0
- package/dist/commands/compact.js +265 -0
- package/dist/commands/cost.js +56 -0
- package/dist/commands/help.js +21 -14
- package/dist/config/loader.js +39 -0
- package/dist/cost/cost-log.js +113 -0
- package/dist/cost/pricing.js +91 -0
- package/dist/one-shot.js +1 -1
- package/dist/renderer/markdown.js +7 -1
- package/dist/renderer/status-footer.js +37 -14
- package/dist/renderer/thinking-heartbeat.js +15 -3
- package/dist/renderer/tool-widget.js +6 -1
- package/dist/renderer/tty.js +27 -0
- package/dist/repl/cspeach-shell-detect.js +33 -0
- package/dist/repl/post-turn-status.js +68 -0
- package/dist/repl/slash-completer.js +2 -0
- package/dist/repl/slash-picker.js +2 -0
- package/dist/repl.js +371 -46
- package/dist/sap-errors/parse-adt-exception.js +149 -0
- package/dist/session/schema.js +2 -1
- package/dist/session/store.js +19 -0
- package/dist/tools/_filesystem-shared.js +9 -1
- package/dist/tools/ask-question.js +19 -0
- package/dist/tools/sap-read.js +56 -4
- package/dist/tools/sap-write.js +19 -0
- package/dist/tools/subagent/schedule_draft_create.js +137 -0
- package/dist/ui/alt-screen.js +120 -0
- package/dist/ui/app.js +47 -15
- package/dist/ui/ask-question-emitter.js +13 -0
- package/dist/ui/body.js +39 -23
- package/dist/ui/coaching-picker-classic.js +8 -1
- package/dist/ui/footer.js +10 -2
- package/dist/ui/header.js +19 -4
- package/dist/ui/sap-state-store.js +13 -1
- package/dist/ui/sidebar.js +5 -3
- package/dist/ui/status-emitter.js +19 -0
- package/dist/ui/status-row.js +30 -4
- package/dist/ui/widgets/ask-question-modal.js +132 -0
- package/dist/ui/widgets/coaching-picker.js +55 -8
- package/package.json +1 -1
|
@@ -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(/</g, '<')
|
|
99
|
+
.replace(/>/g, '>')
|
|
100
|
+
.replace(/"/g, '"')
|
|
101
|
+
.replace(/'/g, "'")
|
|
102
|
+
// & must run LAST so we don't double-decode (&lt; → < → <).
|
|
103
|
+
.replace(/&/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
|
+
}
|
package/dist/session/schema.js
CHANGED
|
@@ -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
|
}
|
package/dist/session/store.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -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
|
|
43
|
-
|
|
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
|
// -----------------------------------------------------------------------
|
package/dist/tools/sap-write.js
CHANGED
|
@@ -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; }
|