@cspeach/cli 1.1.18 → 1.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/dist/agent/anthropic-provider.js +30 -10
- package/dist/agent/cache-keepalive.js +162 -0
- package/dist/agent/cold-prune.js +116 -0
- package/dist/agent/loop.js +804 -159
- package/dist/agent/provider-shape.js +263 -0
- package/dist/agent/providers/ai-hub-provider.js +17 -2
- package/dist/agent/providers/byok-provider.js +33 -3
- package/dist/agent/providers/local-provider.js +12 -2
- package/dist/agent/repair-partial.js +125 -7
- package/dist/agent/summarise-via-provider.js +6 -1
- package/dist/agent/system-prompt.js +38 -0
- package/dist/agent/tool-dispatch.js +66 -21
- package/dist/agent/tool-loading-pin.js +100 -0
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +36 -6
- package/dist/commands/auto-compact.js +33 -16
- package/dist/commands/compact.js +49 -11
- package/dist/commands/config-set.js +16 -3
- package/dist/commands/config-show.js +18 -5
- package/dist/commands/cost.js +54 -35
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-audit.js +1 -0
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +70 -5
- package/dist/cost/cost-log.js +62 -2
- package/dist/cost/pricing.js +11 -6
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lib/spill-labels.js +13 -0
- package/dist/lock-contention.js +2 -2
- package/dist/models/resolve.js +93 -2
- package/dist/models/server-config.js +158 -3
- package/dist/one-shot.js +150 -45
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/image-attachments.js +15 -2
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +100 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +154 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +269 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +55 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/current-transport.js +13 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +43 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +863 -339
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/repin-model.js +18 -0
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +84 -28
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/preamble.js +75 -0
- package/dist/skills/source-manifest.js +11 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-read.js +11 -1
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/result-spill.js +238 -0
- package/dist/tools/sap-read.js +74 -15
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +39 -12
- package/dist/tools/subagent/adt-serial.js +33 -0
- package/dist/tools/subagent/agent_run.js +2 -0
- package/dist/tools/subagent/read_agent.js +178 -0
- package/dist/tools/subagent/reader-prompt.js +48 -0
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +26 -19
- package/dist/tools/tool-loading.js +255 -0
- package/dist/tools/tool-output-read.js +117 -0
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +39 -8
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +2 -1
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +65 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +12 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
|
@@ -3,11 +3,20 @@ import { getTool, isStandalone } from '../tools/index.js';
|
|
|
3
3
|
import { NOT_CONNECTED_MESSAGE } from '../sap/offline-adt-client.js';
|
|
4
4
|
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
5
5
|
import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
|
|
6
|
+
import { isCredentialHandoverApproved, markCredentialHandoverApproved, } from '../repl/credential-handover-gate.js';
|
|
7
|
+
import { READER_TOOLS } from '../tools/subagent/reader-prompt.js';
|
|
8
|
+
const READER_TOOL_SET = new Set(READER_TOOLS);
|
|
6
9
|
export async function dispatchTool(name, args, ctx) {
|
|
7
10
|
const tool = getTool(name);
|
|
8
11
|
if (!tool) {
|
|
9
12
|
return { content: JSON.stringify({ error: `unknown_tool: ${name}` }), is_error: true };
|
|
10
13
|
}
|
|
14
|
+
// Task 18 (ruling F22) — a reader subagent is read-only and depth 1: only
|
|
15
|
+
// its fixed tool list runs, whatever the model calls (no writes, no
|
|
16
|
+
// questions or approvals, no nested read_agent / agent_run, no shell).
|
|
17
|
+
if (ctx.readerMode && !READER_TOOL_SET.has(name)) {
|
|
18
|
+
return { content: `error: read-only reader — ${name} is not allowed here`, is_error: true };
|
|
19
|
+
}
|
|
11
20
|
// Standalone safety net (2026-09-16). SAP-dependent tools are already
|
|
12
21
|
// filtered out of the list the model sees (agent/loop.ts), but a model can
|
|
13
22
|
// still call a tool it remembers from an earlier turn or from training. Fail
|
|
@@ -23,6 +32,37 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
23
32
|
is_error: true,
|
|
24
33
|
};
|
|
25
34
|
}
|
|
35
|
+
// L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
|
|
36
|
+
// login to a local child process ALWAYS asks a human, once per session per
|
|
37
|
+
// alias, even as the first mutating call of a turn (where Rule 8 below stays
|
|
38
|
+
// silent). Headless runs are declined, never auto-approved.
|
|
39
|
+
// The hook may be async (it reads the app's files). A hook that throws is
|
|
40
|
+
// doubt — never let the call through unprompted: fail the call instead.
|
|
41
|
+
let handover = null;
|
|
42
|
+
try {
|
|
43
|
+
handover = (await tool.credentialHandover?.(args, ctx)) ?? null;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return {
|
|
47
|
+
content: JSON.stringify({ error: 'credential_handover_check_failed', reason: String(err) }),
|
|
48
|
+
is_error: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const sessionId = ctx.session?.id;
|
|
52
|
+
if (handover && !isCredentialHandoverApproved(sessionId, handover.alias)) {
|
|
53
|
+
const r = await presentSafetyConfirmation({
|
|
54
|
+
rule: 'CREDENTIAL_HANDOVER',
|
|
55
|
+
op: tool.name,
|
|
56
|
+
object: { type: 'SAP login', name: handover.alias },
|
|
57
|
+
what_will_happen: handover.summary,
|
|
58
|
+
rollback_available: false, // a handed-over secret cannot be taken back
|
|
59
|
+
transport: '',
|
|
60
|
+
args,
|
|
61
|
+
});
|
|
62
|
+
if (!r.confirmed)
|
|
63
|
+
return declined(r);
|
|
64
|
+
markCredentialHandoverApproved(sessionId, handover.alias);
|
|
65
|
+
}
|
|
26
66
|
// Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
|
|
27
67
|
// explicit confirmation. Fires regardless of /safety-mode toggle (batch
|
|
28
68
|
// detection is unconditional per Plan 3 §6.6).
|
|
@@ -49,28 +89,10 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
49
89
|
rollback_available: true, // snapshots taken per-write
|
|
50
90
|
transport: typeof args?.transport === 'string' ? args.transport : '',
|
|
51
91
|
batch_count: getWriteOpsThisTurn().length + 1,
|
|
92
|
+
args,
|
|
52
93
|
});
|
|
53
|
-
if (!r.confirmed)
|
|
54
|
-
|
|
55
|
-
// saw the card) must not masquerade as a user decision: the model
|
|
56
|
-
// (and any transcript reader) reacts differently to "the user said
|
|
57
|
-
// no" vs "no user was available to say yes". Uniform `headless: true`
|
|
58
|
-
// marker matches the other headless result payloads.
|
|
59
|
-
if (r.headless) {
|
|
60
|
-
return {
|
|
61
|
-
content: JSON.stringify({
|
|
62
|
-
error: 'headless_safety_decline',
|
|
63
|
-
headless: true,
|
|
64
|
-
reason: r.reason,
|
|
65
|
-
}),
|
|
66
|
-
is_error: true,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
return {
|
|
70
|
-
content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
|
|
71
|
-
is_error: true,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
94
|
+
if (!r.confirmed)
|
|
95
|
+
return declined(r);
|
|
74
96
|
}
|
|
75
97
|
try {
|
|
76
98
|
const result = await tool.handler(args, ctx);
|
|
@@ -88,6 +110,29 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
88
110
|
return { content: JSON.stringify({ error: String(err) }), is_error: true };
|
|
89
111
|
}
|
|
90
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* B5 — discriminate WHO declined a safety card. A headless fail-fast (nobody
|
|
115
|
+
* ever saw the card) must not masquerade as a user decision: the model (and
|
|
116
|
+
* any transcript reader) reacts differently to "the user said no" vs "no user
|
|
117
|
+
* was available to say yes". Uniform `headless: true` marker matches the
|
|
118
|
+
* other headless result payloads.
|
|
119
|
+
*/
|
|
120
|
+
function declined(r) {
|
|
121
|
+
if (r.headless) {
|
|
122
|
+
return {
|
|
123
|
+
content: JSON.stringify({
|
|
124
|
+
error: 'headless_safety_decline',
|
|
125
|
+
headless: true,
|
|
126
|
+
reason: r.reason,
|
|
127
|
+
}),
|
|
128
|
+
is_error: true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
|
|
133
|
+
is_error: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
91
136
|
/**
|
|
92
137
|
* True for local project work: the filesystem tools (confined to the project
|
|
93
138
|
* folder by resolveSafePath) and shell_exec (a hardcoded safelist of local build
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Final review I2 (controller ruling, 2026-09-28) — the tool-loading mode is
|
|
3
|
+
* pinned on the SESSION, not resolved per process.
|
|
4
|
+
*
|
|
5
|
+
* The first request pins `session.tool_loading` to the mode that request
|
|
6
|
+
* uses (the process-resolved mode, gated by the model allow-list). A resumed
|
|
7
|
+
* session keeps its pinned mode: a 'static' session never turns deferred
|
|
8
|
+
* (its `tools[]` stays byte-stable), and a 'deferred' session stays deferred
|
|
9
|
+
* while the call allows it.
|
|
10
|
+
*
|
|
11
|
+
* A 'deferred' session must become 'static' when a call cannot take the
|
|
12
|
+
* deferred shapes: the admin kill switch, a failed startup config fetch
|
|
13
|
+
* (static until restart), `CSPEACH_TOOL_LOADING=static`, or a call on a model
|
|
14
|
+
* outside the allow-list (a plan-tier sonnet turn, an adopted opus-4-8). Its
|
|
15
|
+
* history then still holds deferred artefacts — `server_tool_use` /
|
|
16
|
+
* `tool_search_tool_result` blocks and `role: 'system'` tool_addition
|
|
17
|
+
* messages — which a request with no search tool and no deferred entries
|
|
18
|
+
* cannot carry. They are stripped ONCE, before that request, and the session
|
|
19
|
+
* is re-pinned 'static' so the strip never repeats. This is a deliberate
|
|
20
|
+
* one-time prefix rewrite, like /compact: every assistant message from the
|
|
21
|
+
* first edit on loses its thinking blocks (the API binds thinking to the exact
|
|
22
|
+
* prefix that produced it), the same stripThinking /compact and the cold
|
|
23
|
+
* prune apply.
|
|
24
|
+
*/
|
|
25
|
+
import { stripThinking } from '../commands/compact.js';
|
|
26
|
+
import { modelAcceptsDeferredTools } from '../tools/tool-loading.js';
|
|
27
|
+
const SERVER_TOOL_BLOCK_TYPES = new Set(['server_tool_use', 'tool_search_tool_result']);
|
|
28
|
+
/** True when the history holds a shape only a deferred-mode request may carry. */
|
|
29
|
+
export function hasDeferredArtefacts(messages) {
|
|
30
|
+
for (const m of messages ?? []) {
|
|
31
|
+
if (m?.role === 'system')
|
|
32
|
+
return true;
|
|
33
|
+
if (Array.isArray(m?.content) && m.content.some((b) => SERVER_TOOL_BLOCK_TYPES.has(b?.type)))
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Remove every deferred artefact from `messages` in place: `role: 'system'`
|
|
40
|
+
* messages, and `server_tool_use` / `tool_search_tool_result` blocks (a
|
|
41
|
+
* message left with no content is dropped). Every assistant message from the
|
|
42
|
+
* first edit on loses its thinking blocks (a thinking-only message keeps a
|
|
43
|
+
* placeholder text). Returns the number of messages and blocks removed; 0 ⇒
|
|
44
|
+
* untouched. Idempotent.
|
|
45
|
+
*/
|
|
46
|
+
export function stripDeferredArtefacts(messages) {
|
|
47
|
+
let removed = 0;
|
|
48
|
+
let firstEdit = -1;
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const m of messages) {
|
|
51
|
+
if (m?.role === 'system') {
|
|
52
|
+
removed++;
|
|
53
|
+
if (firstEdit < 0)
|
|
54
|
+
firstEdit = out.length;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(m?.content)) {
|
|
58
|
+
const kept = m.content.filter((b) => !SERVER_TOOL_BLOCK_TYPES.has(b?.type));
|
|
59
|
+
if (kept.length !== m.content.length) {
|
|
60
|
+
removed += m.content.length - kept.length;
|
|
61
|
+
if (firstEdit < 0)
|
|
62
|
+
firstEdit = out.length;
|
|
63
|
+
if (kept.length > 0)
|
|
64
|
+
out.push({ ...m, content: kept });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
out.push(m);
|
|
69
|
+
}
|
|
70
|
+
if (removed === 0)
|
|
71
|
+
return 0;
|
|
72
|
+
for (let i = firstEdit; i < out.length; i++)
|
|
73
|
+
out[i] = stripThinking(out[i]);
|
|
74
|
+
messages.splice(0, messages.length, ...out);
|
|
75
|
+
return removed;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Decide the tool-loading mode for one call and keep the session consistent
|
|
79
|
+
* with it. `resolved` is the process-resolved mode (resolveToolLoading);
|
|
80
|
+
* `model` is the model this call sends. Mutates `session.tool_loading` and,
|
|
81
|
+
* on a deferred → static change, `session.messages` (see the file comment).
|
|
82
|
+
*/
|
|
83
|
+
export function settleSessionToolLoading(session, i) {
|
|
84
|
+
const allowed = i.resolved === 'deferred' && modelAcceptsDeferredTools(i.model) ? 'deferred' : 'static';
|
|
85
|
+
// A session saved before the pin existed: its history tells how it ran.
|
|
86
|
+
const pinned = session.tool_loading
|
|
87
|
+
?? (hasDeferredArtefacts(session.messages) ? 'deferred' : undefined);
|
|
88
|
+
if (pinned === undefined) {
|
|
89
|
+
session.tool_loading = allowed;
|
|
90
|
+
return { mode: allowed, stripped: 0 };
|
|
91
|
+
}
|
|
92
|
+
if (pinned === 'deferred' && allowed === 'deferred') {
|
|
93
|
+
session.tool_loading = 'deferred';
|
|
94
|
+
return { mode: 'deferred', stripped: 0 };
|
|
95
|
+
}
|
|
96
|
+
// Static for this call (and from now on): strip once, pin 'static'.
|
|
97
|
+
session.tool_loading = 'static';
|
|
98
|
+
const stripped = pinned === 'deferred' ? stripDeferredArtefacts(session.messages) : 0;
|
|
99
|
+
return { mode: 'static', stripped };
|
|
100
|
+
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* everything. The new behaviour:
|
|
6
6
|
*
|
|
7
7
|
* - Writes the full error (message + stack) to ~/.cspeach/logs/<sessionId>-<ts>.log
|
|
8
|
-
* - Prints
|
|
9
|
-
* stream path, log path
|
|
8
|
+
* - Prints the piece 2 failure shape to stderr: cause, session id,
|
|
9
|
+
* stream path, log path and resume facts as dim detail, then one Next: line.
|
|
10
10
|
* - Never throws — best-effort. The CLI returns to the prompt cleanly so
|
|
11
11
|
* the user can either resume or type a new prompt.
|
|
12
12
|
*
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* pushed any partial assistant content onto session.messages and saved
|
|
15
15
|
* the session before the throw bubbled here. Resume picks up that state.
|
|
16
16
|
*/
|
|
17
|
-
import
|
|
17
|
+
import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
|
|
18
|
+
import { paint } from '../renderer/theme.js';
|
|
19
|
+
import { getLook } from '../renderer/look.js';
|
|
18
20
|
import { promises as fs } from 'node:fs';
|
|
19
21
|
import * as os from 'node:os';
|
|
20
22
|
import * as path from 'node:path';
|
|
@@ -48,7 +50,37 @@ export function describeError(err) {
|
|
|
48
50
|
return 'network timeout';
|
|
49
51
|
if (msg.toLowerCase().includes('aborterror'))
|
|
50
52
|
return 'request aborted';
|
|
51
|
-
|
|
53
|
+
const api = describeApiError(err, msg);
|
|
54
|
+
if (api)
|
|
55
|
+
return api;
|
|
56
|
+
return msg.replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 2026-09-27 — an upstream API error arrives as `Error: 400 {"type":"error",
|
|
60
|
+
* "error":{…,"message":"…"},"request_id":"…"}`. Show ONE readable line
|
|
61
|
+
* (`service error 400: <message>`), never the raw JSON body.
|
|
62
|
+
*/
|
|
63
|
+
function describeApiError(err, msg) {
|
|
64
|
+
const brace = msg.indexOf('{');
|
|
65
|
+
if (brace < 0)
|
|
66
|
+
return null;
|
|
67
|
+
const end = msg.lastIndexOf('}');
|
|
68
|
+
let body;
|
|
69
|
+
try {
|
|
70
|
+
body = JSON.parse(msg.slice(brace, end + 1));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (!body || typeof body !== 'object')
|
|
76
|
+
return null;
|
|
77
|
+
const head = msg.slice(0, brace).match(/(\d{3})\s*$/);
|
|
78
|
+
const statusRaw = head?.[1] ?? err?.status;
|
|
79
|
+
const status = statusRaw != null && /^\d{3}$/.test(String(statusRaw)) ? String(statusRaw) : null;
|
|
80
|
+
const inner = body?.error?.message ?? body?.message;
|
|
81
|
+
const text = typeof inner === 'string' ? inner.replace(/\s+/g, ' ').trim() : '';
|
|
82
|
+
const label = status ? `service error ${status}` : 'service error';
|
|
83
|
+
return (text ? `${label}: ${text}` : label).slice(0, 200);
|
|
52
84
|
}
|
|
53
85
|
/**
|
|
54
86
|
* Write a full diagnostic log to ~/.cspeach/logs/<sessionId>-<ts>.log.
|
|
@@ -96,38 +128,57 @@ async function writeErrorLog(sessionId, err) {
|
|
|
96
128
|
return null;
|
|
97
129
|
}
|
|
98
130
|
}
|
|
131
|
+
/** Print a multi-line block through the line-based `print` sink. */
|
|
132
|
+
function printBlock(print, block) {
|
|
133
|
+
for (const l of block.replace(/\n$/, '').split('\n'))
|
|
134
|
+
print(l);
|
|
135
|
+
}
|
|
99
136
|
/**
|
|
100
|
-
* Render the user-facing turn-error block
|
|
101
|
-
*
|
|
137
|
+
* Render the user-facing turn-error block (piece 2 §9 failure shape). Writes a
|
|
138
|
+
* log file (best-effort) and prints the cause plus the Session, Stream, Log and
|
|
139
|
+
* Resume facts as dim detail, ending with ONE Next: line — "type continue"
|
|
140
|
+
* inside a live session, `cspeach --resume <id>` otherwise.
|
|
102
141
|
*/
|
|
103
142
|
export async function renderTurnError(input) {
|
|
104
143
|
const print = input.print ?? ((line) => process.stderr.write(line + '\n'));
|
|
144
|
+
const id = input.session.id;
|
|
145
|
+
const resumeRows = [
|
|
146
|
+
`Resume: cspeach --resume ${id}`,
|
|
147
|
+
` cspeach --resume (no arg → pick from a list)`,
|
|
148
|
+
];
|
|
105
149
|
print('');
|
|
106
150
|
if (input.userInterrupted) {
|
|
107
|
-
// User pressed Ctrl+C / Esc —
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
151
|
+
// User pressed Ctrl+C / Esc — a deliberate cancellation, not a crash.
|
|
152
|
+
// Skip the log file (no diagnostic value). Round 6 item 4d — the panels
|
|
153
|
+
// shape: ONE notice line. Review M4 — the legacy look keeps its rows.
|
|
154
|
+
if (getLook() === 'panels') {
|
|
155
|
+
printBlock(print, formatInfo(input.inSession
|
|
156
|
+
? 'Turn cancelled · your work so far is kept · type continue to pick up'
|
|
157
|
+
: `Turn cancelled · resume: cspeach --resume ${id}`));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
printBlock(print, formatInfo('Turn cancelled'));
|
|
161
|
+
for (const r of [`Session: ${id}`, ...resumeRows])
|
|
162
|
+
print(` ${paint('dim', r)}`);
|
|
163
|
+
}
|
|
113
164
|
print('');
|
|
114
165
|
return;
|
|
115
166
|
}
|
|
116
167
|
const reason = describeError(input.err);
|
|
117
|
-
const logPath = await writeErrorLog(
|
|
118
|
-
|
|
119
|
-
if (input.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
168
|
+
const logPath = await writeErrorLog(id, input.err);
|
|
169
|
+
const detail = [reason, `Session: ${id}`];
|
|
170
|
+
if (input.streamFilePath)
|
|
171
|
+
detail.push(`Stream: ${input.streamFilePath}`);
|
|
172
|
+
if (logPath)
|
|
173
|
+
detail.push(`Log: ${logPath}`);
|
|
174
|
+
detail.push(...resumeRows);
|
|
175
|
+
printBlock(print, formatFailure({
|
|
176
|
+
what: 'Turn interrupted',
|
|
177
|
+
detail,
|
|
178
|
+
next: input.inSession
|
|
179
|
+
? 'type continue to pick up where it stopped (your work so far is saved)'
|
|
180
|
+
: `cspeach --resume ${id}`,
|
|
181
|
+
}));
|
|
131
182
|
print('');
|
|
132
183
|
}
|
|
133
184
|
/** Re-export for callers that want to compute a stream path independently. */
|
|
@@ -29,6 +29,17 @@ export function streamsRoot() {
|
|
|
29
29
|
export class TurnStreamWriter {
|
|
30
30
|
filePath;
|
|
31
31
|
headerWritten = false;
|
|
32
|
+
/**
|
|
33
|
+
* Fix round 3 (item 11a) — every write runs after the previous one. The
|
|
34
|
+
* loop calls append() per text delta WITHOUT awaiting, so unserialised
|
|
35
|
+
* appendFile calls interleaved on disk (a scrambled log) and two racing
|
|
36
|
+
* first appends could each write the header.
|
|
37
|
+
*/
|
|
38
|
+
chain = Promise.resolve();
|
|
39
|
+
enqueue(work) {
|
|
40
|
+
this.chain = this.chain.then(work, work);
|
|
41
|
+
return this.chain;
|
|
42
|
+
}
|
|
32
43
|
constructor(sessionId, startedAt = new Date()) {
|
|
33
44
|
// ISO timestamp with filename-safe punctuation.
|
|
34
45
|
const stamp = startedAt.toISOString().replace(/[:.]/g, '-');
|
|
@@ -43,9 +54,12 @@ export class TurnStreamWriter {
|
|
|
43
54
|
* file-IO errors so a write hiccup never kills a working turn. Lazily writes
|
|
44
55
|
* a small header on the first append so an unused turn produces no file.
|
|
45
56
|
*/
|
|
46
|
-
|
|
57
|
+
append(text) {
|
|
47
58
|
if (!text || text.length === 0)
|
|
48
|
-
return;
|
|
59
|
+
return Promise.resolve();
|
|
60
|
+
return this.enqueue(() => this.write(text));
|
|
61
|
+
}
|
|
62
|
+
async write(text) {
|
|
49
63
|
try {
|
|
50
64
|
if (!this.headerWritten) {
|
|
51
65
|
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
@@ -63,7 +77,15 @@ export class TurnStreamWriter {
|
|
|
63
77
|
* Close the stream with an optional footer (token totals, duration, end
|
|
64
78
|
* status). Idempotent — safe to call multiple times.
|
|
65
79
|
*/
|
|
66
|
-
|
|
80
|
+
close(footer) {
|
|
81
|
+
return this.enqueue(() => this.writeFooter(footer));
|
|
82
|
+
}
|
|
83
|
+
async writeFooter(footer) {
|
|
84
|
+
// Round 6 item 4e — a turn cut before it streamed any text (a cancel
|
|
85
|
+
// while thinking, /exit mid-turn) still gets a log with its footer, so
|
|
86
|
+
// the goodbye can point at it. A clean turn with no text writes nothing.
|
|
87
|
+
if (!this.headerWritten && footer)
|
|
88
|
+
await this.write('');
|
|
67
89
|
if (!this.headerWritten)
|
|
68
90
|
return;
|
|
69
91
|
try {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-review item 3 — one normaliser for object types, shared by the syntax
|
|
3
|
+
* state (tools/syntax-state.ts) and the 8e risk-floor backstop. Approvals
|
|
4
|
+
* carry whatever type word the model chose ("CLASS", "Program", "PROG/P");
|
|
5
|
+
* the syntax state records the tool's ADT code. Unknown words return
|
|
6
|
+
* undefined so the caller can fall back to a name-only match.
|
|
7
|
+
*/
|
|
8
|
+
const ALIASES = {
|
|
9
|
+
CLASS: 'CLAS', PROGRAM: 'PROG', REPORT: 'PROG', INTERFACE: 'INTF',
|
|
10
|
+
'FUNCTION GROUP': 'FUGR', FUNCTIONGROUP: 'FUGR', 'FUNCTION MODULE': 'FUNC', FUNCTION: 'FUNC',
|
|
11
|
+
TABLE: 'TABL', STRUCTURE: 'TABL', 'DATA ELEMENT': 'DTEL', DOMAIN: 'DOMA', INCLUDE: 'PROG',
|
|
12
|
+
'CDS VIEW': 'DDLS', CDS: 'DDLS', 'CDS VIEW ENTITY': 'DDLS', 'BEHAVIOR DEFINITION': 'BDEF', 'BEHAVIOUR DEFINITION': 'BDEF',
|
|
13
|
+
'SERVICE DEFINITION': 'SRVD', 'SERVICE BINDING': 'SRVB', 'METADATA EXTENSION': 'DDLX', 'ACCESS CONTROL': 'DCLS',
|
|
14
|
+
'MESSAGE CLASS': 'MSAG', PACKAGE: 'DEVC',
|
|
15
|
+
};
|
|
16
|
+
const CODES = new Set([
|
|
17
|
+
'CLAS', 'PROG', 'INTF', 'FUGR', 'FUNC', 'TABL', 'DTEL', 'DOMA', 'DDLS', 'BDEF', 'SRVD', 'SRVB',
|
|
18
|
+
'DDLX', 'DCLS', 'MSAG', 'DEVC', 'ENHO', 'XSLT', 'TTYP', 'VIEW', 'SHLP', 'NROB',
|
|
19
|
+
]);
|
|
20
|
+
/** ADT code (CLAS, PROG, …) for a type word, or undefined when unknown. */
|
|
21
|
+
export function normaliseAdtType(type) {
|
|
22
|
+
const t = (type ?? '').trim().toUpperCase().replace(/\s+/g, ' ');
|
|
23
|
+
const head = t.split('/')[0].trim();
|
|
24
|
+
if (CODES.has(head))
|
|
25
|
+
return head;
|
|
26
|
+
return ALIASES[t] ?? ALIASES[head];
|
|
27
|
+
}
|
|
@@ -1,7 +1,55 @@
|
|
|
1
1
|
import clipboardy from 'clipboardy';
|
|
2
2
|
import readline from 'node:readline/promises';
|
|
3
3
|
import { stdin as input, stdout as output } from 'node:process';
|
|
4
|
+
import { shouldUseInk } from '../renderer/tty.js';
|
|
5
|
+
import { emitTrace, formatDecisionTrace } from '../renderer/trace.js';
|
|
6
|
+
import { paint } from '../renderer/theme.js';
|
|
7
|
+
import { getGlyphs } from '../renderer/glyphs.js';
|
|
8
|
+
import { askConfirm } from '../ui/confirm-request.js';
|
|
9
|
+
const ADVISORY_LABEL = { applied: 'I applied it', skipped: 'Skip', aborted: 'Stop' };
|
|
10
|
+
/**
|
|
11
|
+
* Piece 2 §6.2 — Ink: the proposal goes to scrollback once; a confirm card
|
|
12
|
+
* holds the choices. `c` copies, shows "· Copied" and asks again (not a
|
|
13
|
+
* decision, as today). y/c/s/n and the words yes/skip/abort still work.
|
|
14
|
+
* Esc / no listener → 'aborted': stop, nothing is written. The cursor starts
|
|
15
|
+
* on Copy, so a bare Enter decides nothing (as a bare Enter does today).
|
|
16
|
+
*/
|
|
17
|
+
async function promptAdvisoryInk(args) {
|
|
18
|
+
emitTrace(args.displayText);
|
|
19
|
+
for (;;) {
|
|
20
|
+
const res = await askConfirm({
|
|
21
|
+
id: 'advisory-proposal',
|
|
22
|
+
tag: 'ADVISORY',
|
|
23
|
+
title: 'Did you apply this change in ADT or SE80?',
|
|
24
|
+
facts: ['The proposal is shown above.', 'Nothing is written to SAP from here.'],
|
|
25
|
+
choices: [
|
|
26
|
+
{ value: 'applied', label: ADVISORY_LABEL.applied },
|
|
27
|
+
{ value: 'copy', label: 'Copy to clipboard' },
|
|
28
|
+
{ value: 'skipped', label: ADVISORY_LABEL.skipped },
|
|
29
|
+
{ value: 'aborted', label: ADVISORY_LABEL.aborted },
|
|
30
|
+
],
|
|
31
|
+
initial: 1,
|
|
32
|
+
aliases: {
|
|
33
|
+
y: 'applied', yes: 'applied', applied: 'applied',
|
|
34
|
+
c: 'copy', copy: 'copy',
|
|
35
|
+
s: 'skipped', skip: 'skipped', skipped: 'skipped',
|
|
36
|
+
n: 'aborted', no: 'aborted', abort: 'aborted', aborted: 'aborted',
|
|
37
|
+
},
|
|
38
|
+
footnote: 'Advisory mode',
|
|
39
|
+
}, { trace: false });
|
|
40
|
+
if (res.value === 'copy') {
|
|
41
|
+
await clipboardy.write(args.clipboardText);
|
|
42
|
+
emitTrace(paint('dim', `${getGlyphs().info} Copied`));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const outcome = res.value === 'applied' ? 'applied' : res.value === 'skipped' ? 'skipped' : 'aborted';
|
|
46
|
+
emitTrace(formatDecisionTrace('Advisory proposal', ADVISORY_LABEL[outcome]));
|
|
47
|
+
return outcome;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
4
50
|
export async function promptAdvisory(args) {
|
|
51
|
+
if (shouldUseInk())
|
|
52
|
+
return promptAdvisoryInk(args);
|
|
5
53
|
output.write(args.displayText + '\n');
|
|
6
54
|
const rl = readline.createInterface({ input, output });
|
|
7
55
|
try {
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
|
|
2
|
+
import { paint } from '../renderer/theme.js';
|
|
3
|
+
import { uiWidth } from '../renderer/ui-width.js';
|
|
4
|
+
import { sanitizeControlChars as clean } from '../renderer/sanitize.js';
|
|
5
|
+
const ADVISORY_TITLE = ' ADVISORY MODE — proposed change (no SAP write will occur) ';
|
|
6
|
+
export const ADVISORY_KEY_HINT = 'Press c to copy to clipboard, y when applied, s to skip, n to abort.';
|
|
7
|
+
export function renderAdvisoryProposal(args, opts = {}) {
|
|
8
|
+
// Model text is shown with control characters made harmless (it must not
|
|
9
|
+
// drive the terminal); the clipboard copy keeps it verbatim.
|
|
3
10
|
const displayParts = [];
|
|
4
11
|
const clipboardParts = [];
|
|
5
|
-
|
|
6
|
-
|
|
12
|
+
// Borders line up: top and bottom share one width (§1.4 rule, widened
|
|
13
|
+
// only if the title itself would not fit).
|
|
14
|
+
const w = Math.max(uiWidth(), ADVISORY_TITLE.length + 6);
|
|
15
|
+
const top = '╔══' + ADVISORY_TITLE + '═'.repeat(w - 4 - ADVISORY_TITLE.length) + '╗';
|
|
16
|
+
const bottom = '╚' + '═'.repeat(w - 2) + '╝';
|
|
17
|
+
const rule = paint('dim', '─'.repeat(w));
|
|
18
|
+
displayParts.push(chalk.bold(paint('warn', top)));
|
|
19
|
+
displayParts.push(chalk.bold(`Summary: ${clean(args.summary)}`));
|
|
7
20
|
displayParts.push(`Risk: ${args.risk}`);
|
|
8
21
|
if (args.transport)
|
|
9
|
-
displayParts.push(`Target transport: ${args.transport}`);
|
|
22
|
+
displayParts.push(`Target transport: ${clean(args.transport)}`);
|
|
10
23
|
displayParts.push('');
|
|
11
24
|
clipboardParts.push(`# ${args.summary}`);
|
|
12
25
|
if (args.transport)
|
|
@@ -14,21 +27,22 @@ export function renderAdvisoryProposal(args) {
|
|
|
14
27
|
clipboardParts.push('');
|
|
15
28
|
args.changes.forEach((change, idx) => {
|
|
16
29
|
const header = `Change ${idx + 1}: ${change.op.toUpperCase()} ${change.type} ${change.object}`;
|
|
17
|
-
displayParts.push(
|
|
30
|
+
displayParts.push(paint('sap', clean(header)));
|
|
18
31
|
clipboardParts.push(`## ${header}`);
|
|
19
32
|
if (change.diff) {
|
|
20
|
-
displayParts.push(
|
|
21
|
-
displayParts.push(change.diff);
|
|
22
|
-
displayParts.push(
|
|
33
|
+
displayParts.push(rule);
|
|
34
|
+
displayParts.push(clean(change.diff));
|
|
35
|
+
displayParts.push(rule);
|
|
23
36
|
clipboardParts.push('');
|
|
24
37
|
clipboardParts.push(change.diff);
|
|
25
38
|
clipboardParts.push('');
|
|
26
39
|
}
|
|
27
40
|
});
|
|
28
41
|
displayParts.push('');
|
|
29
|
-
displayParts.push(
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
displayParts.push(paint('warn', 'Next step: open this object in ADT or SE80, apply the change, save and activate.'));
|
|
43
|
+
if (opts.interactiveHint !== false)
|
|
44
|
+
displayParts.push(paint('dim', ADVISORY_KEY_HINT));
|
|
45
|
+
displayParts.push(chalk.bold(paint('warn', bottom)));
|
|
32
46
|
clipboardParts.push('');
|
|
33
47
|
clipboardParts.push('# This is an advisory proposal. Apply manually in ADT/SE80.');
|
|
34
48
|
return {
|