@cspeach/cli 1.1.17 → 1.1.19
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/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +80 -22
- 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 +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- 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-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 +15 -3
- package/dist/cost/pricing.js +6 -5
- 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/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- 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 +96 -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 +152 -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 +238 -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 +42 -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/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 +36 -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 +795 -332
- 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/resume.js +104 -36
- package/dist/session/store.js +69 -27
- 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/signing-public-key.js +1 -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-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/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- 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 +1 -0
- 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 +28 -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 +11 -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
package/dist/repl.js
CHANGED
|
@@ -6,37 +6,58 @@ import chalk from 'chalk';
|
|
|
6
6
|
// callback-module's InterfaceConstructor signature or import from
|
|
7
7
|
// 'node:readline' directly for that specific operation.
|
|
8
8
|
import * as readline from 'node:readline/promises';
|
|
9
|
-
import { select, password as promptPassword } from '
|
|
10
|
-
import { loadConfig, resolveLocalBuild, resolveStandalone } from './config/loader.js';
|
|
9
|
+
import { select, password as promptPassword } from './repl/themed-prompts.js';
|
|
10
|
+
import { loadConfig, resolveLocalBuild, resolveStandalone, DEFAULT_CONFIG } from './config/loader.js';
|
|
11
|
+
import { getLook, setLook, setBackground } from './renderer/look.js';
|
|
12
|
+
import { applyColorMode } from './renderer/color-mode.js';
|
|
13
|
+
import { startEarlyBrand, aliasSourceNote, panelsHintLine, panelsResumeLine, panelsStartTail, panelsInterruptedNote, formatPromptEcho } from './renderer/startup-lines.js';
|
|
14
|
+
import { formatAttention, formatFailure, formatInfo } from './renderer/notice-shape.js';
|
|
15
|
+
import { formatPanel } from './renderer/panel-rows.js';
|
|
16
|
+
import { sanitizeControlChars } from './renderer/sanitize.js';
|
|
17
|
+
import { formatRoutingLine } from './renderer/routing-line.js';
|
|
18
|
+
import { handleUiLook, handleUiRendering } from './repl/ui-look-command.js';
|
|
19
|
+
import { CLI_VERSION } from './lib/version.js';
|
|
11
20
|
import { createOfflineAdtClient } from './sap/offline-adt-client.js';
|
|
12
21
|
import { STANDALONE_ALIAS } from './tools/index.js';
|
|
22
|
+
import { resumeStandaloneDecision, applySystemSwitch } from './repl/resume-standalone.js';
|
|
23
|
+
import { applyResumedSkill, isShortFollowUp } from './router/resume-routing.js';
|
|
24
|
+
import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
|
|
13
25
|
import { getStore } from './auth/api-key.js';
|
|
14
26
|
import { readAuthFile } from './auth/auth-file.js';
|
|
15
27
|
import { fetchMe } from './auth/me.js';
|
|
16
|
-
import { formatLoginBanner } from './ui/login-banner.js';
|
|
17
|
-
import { printStartupBanner, printKeytarFallbackBanner } from './renderer/banners.js';
|
|
28
|
+
import { formatLoginBanner, signInFragment } from './ui/login-banner.js';
|
|
29
|
+
import { printStartupBanner, printKeytarFallbackBanner, keychainFallbackNotice } from './renderer/banners.js';
|
|
30
|
+
import { paint } from './renderer/theme.js';
|
|
31
|
+
import { steeringEchoLine, classifyMidTurnLine, midTurnSlashHint } from './renderer/steering-echo.js';
|
|
18
32
|
import { runAddSapWizard } from './sap/onboarding.js';
|
|
19
33
|
import { getConnection } from './sap/connection-manager.js';
|
|
34
|
+
import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
|
|
20
35
|
import { AdtClient, snapshots } from '@cspeach/sap-client';
|
|
21
|
-
import { makeSessionId, saveSession } from './session/store.js';
|
|
36
|
+
import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
|
|
22
37
|
import { newSession } from './session/schema.js';
|
|
23
38
|
import { resolveModelRole } from './models/resolve.js';
|
|
24
39
|
import { loadServerModelConfig } from './models/server-config.js';
|
|
25
40
|
import { computeCost } from './cost/pricing.js';
|
|
26
|
-
import {
|
|
41
|
+
import { formatSessionSpendValue } from './repl/session-spend-line.js';
|
|
42
|
+
import { formatGoodbye } from './renderer/goodbye.js';
|
|
27
43
|
import { globalCredits } from './cost/credits.js';
|
|
28
44
|
import { initCredits } from './cost/credits-wire.js';
|
|
29
45
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
30
46
|
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
31
47
|
import { runTurn } from './agent/loop.js';
|
|
32
48
|
import { renderTurnError } from './agent/turn-error-ux.js';
|
|
49
|
+
import { runRerouteTurn } from './repl/reroute-turn.js';
|
|
33
50
|
// BUG-2b (2026-07-05 live smoke) — pure (no-Ink) modules, safe to import
|
|
34
51
|
// statically even though App itself is lazy-loaded to keep Ink out of
|
|
35
52
|
// classic mode. Used by the single-flight dispatch guard below.
|
|
36
53
|
import { turnStatusEmitter } from './ui/turn-status-emitter.js';
|
|
54
|
+
import { decideInterrupt, createPendingTurnCancel, pendingCancelLine, queuedWorkLabel, autoDispatchDecision, settleWithin, EXIT_TAIL_CAP_MS } from './ui/interrupt.js';
|
|
55
|
+
import { setTraceEmitter } from './renderer/trace.js';
|
|
37
56
|
import { enqueueSteering } from './agent/steering-queue.js';
|
|
38
57
|
import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
|
|
58
|
+
import { pipedRoutingPick } from './router/piped-routing.js';
|
|
39
59
|
import { decideRouting } from './router/routing-decision.js';
|
|
60
|
+
import { routingFailureFromError, routingFailureFromResult, routingFailureNotice, routingFailureOutcome, routingFailureText } from './router/routing-failure.js';
|
|
40
61
|
import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
|
|
41
62
|
import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
|
|
42
63
|
import { globalStore as sapStateStore } from './ui/sap-state-store.js';
|
|
@@ -51,6 +72,9 @@ import { approvalEmitter } from './ui/approval-emitter.js';
|
|
|
51
72
|
import { collectRewindCandidates } from './rewind/candidates.js';
|
|
52
73
|
import { executeRestore, runClassicRewind } from './rewind/cli.js';
|
|
53
74
|
import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
|
|
75
|
+
import { pipeRevivedStdin } from './repl/reset-tty-stdin.js';
|
|
76
|
+
import { logsDir } from './config/paths.js';
|
|
77
|
+
import { noticeLogPathFor, pruneNoticeLogs, setNoticeLogPath } from './renderer/notice-log.js';
|
|
54
78
|
import { maybePromptWorkspaceMigration, maybeOfferAbapforgeMigration } from './projects/migration.js';
|
|
55
79
|
import { findProjectRoot } from './projects/workspace.js';
|
|
56
80
|
import { slashCompleter } from './repl/slash-completer.js';
|
|
@@ -81,19 +105,26 @@ function prefillReadline(rl, text) {
|
|
|
81
105
|
import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
|
|
82
106
|
import { InkStdinPasteGuard } from './repl/ink-stdin-guard.js';
|
|
83
107
|
import { printHelp } from './commands/help.js';
|
|
84
|
-
import { shouldUseInk,
|
|
108
|
+
import { shouldUseInk, setOneShotOverride, getEffectiveBodyWidth } from './renderer/tty.js';
|
|
109
|
+
import { applyStartupRendering } from './renderer/rendering-mode.js';
|
|
85
110
|
import { createClassicOutputEmitter } from './renderer/notices.js';
|
|
86
111
|
import { saveConfig } from './config/loader.js';
|
|
87
112
|
import { EventEmitter } from 'node:events';
|
|
113
|
+
import { existsSync } from 'node:fs';
|
|
114
|
+
import { join } from 'node:path';
|
|
115
|
+
import { streamsRoot } from './agent/turn-stream.js';
|
|
88
116
|
import { parseAndValidateReroute } from './commands/reroute.js';
|
|
117
|
+
import { emitPlanTurnEnd } from './repl/plan-turn-end.js';
|
|
89
118
|
import { SKILL_CATALOG } from './skill-catalog.js';
|
|
90
|
-
import { replPreviewHook } from './repl/update-method-preview-hook.js';
|
|
119
|
+
import { replPreviewHook, inkPreviewHook } from './repl/update-method-preview-hook.js';
|
|
91
120
|
import { getCurrentTransport, handleTransportCommand } from './repl/current-transport.js';
|
|
92
121
|
import { matchSafetyModeCommand } from './repl/safety-mode-state.js';
|
|
93
122
|
import { getEffectiveWriteMode, runModeCommand } from './repl/mode-cycle.js';
|
|
94
123
|
import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-menu.js';
|
|
95
124
|
import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
|
|
96
|
-
import {
|
|
125
|
+
import { createRestoreGuard } from './repl/restore-guard.js';
|
|
126
|
+
import { gracefulExit } from './lib/graceful-exit.js';
|
|
127
|
+
import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
|
|
97
128
|
import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
|
|
98
129
|
import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
|
|
99
130
|
// Side-effect imports so tool registrations happen before listTools() is called.
|
|
@@ -127,7 +158,6 @@ import './tools/subagent/background_run.js';
|
|
|
127
158
|
import './tools/subagent/monitor_emit.js';
|
|
128
159
|
import './tools/subagent/schedule_draft_create.js';
|
|
129
160
|
import './tools/subagent/agent_run.js';
|
|
130
|
-
const PEACH = '#FFCBA4';
|
|
131
161
|
const HISTORY_SIZE = 500;
|
|
132
162
|
// Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
|
|
133
163
|
// the single writer shared with the Ink Footer (Task 8, ux-wave1).
|
|
@@ -228,6 +258,15 @@ function isAckContinuation(trimmed, sessionSkill) {
|
|
|
228
258
|
* shell shows only the startup banner + a bare "session saved" line,
|
|
229
259
|
* which feels like everything they just did vanished.
|
|
230
260
|
*/
|
|
261
|
+
/** `~/.cspeach/streams/<id>/` when that folder exists, else null (fix round 3, item 11b). */
|
|
262
|
+
function existingStreamDir(sessionId) {
|
|
263
|
+
try {
|
|
264
|
+
return existsSync(join(streamsRoot(), sessionId)) ? `~/.cspeach/streams/${sessionId}/` : null;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
231
270
|
function printGoodbye(session) {
|
|
232
271
|
const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
|
|
233
272
|
const sessionCost = computeCost(session.model, {
|
|
@@ -240,16 +279,18 @@ function printGoodbye(session) {
|
|
|
240
279
|
(session.usage.output_tokens ?? 0) +
|
|
241
280
|
(session.usage.cache_read_input_tokens ?? 0) +
|
|
242
281
|
(session.usage.cache_creation_input_tokens ?? 0);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
282
|
+
for (const line of formatGoodbye({
|
|
283
|
+
sessionId: session.id,
|
|
284
|
+
turns: userTurns,
|
|
285
|
+
lastSkill: session.skill ?? null,
|
|
286
|
+
tokens: totalTokens,
|
|
287
|
+
spend: formatSessionSpendValue(sessionCost, globalCredits.snapshot()),
|
|
288
|
+
logDir: existingStreamDir(session.id),
|
|
289
|
+
// Round 6 review I1 — gate on the file: a start-then-quit (or a double
|
|
290
|
+
// Ctrl+C in the first turn before it saved) has no session to resume.
|
|
291
|
+
saved: sessionSaved(session),
|
|
292
|
+
}))
|
|
293
|
+
console.log(line);
|
|
253
294
|
}
|
|
254
295
|
function isBareAck(trimmed) {
|
|
255
296
|
const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
|
|
@@ -308,7 +349,29 @@ function buildCoachingParams(result, prompt, preSelect, lastSkill) {
|
|
|
308
349
|
};
|
|
309
350
|
}
|
|
310
351
|
export async function runRepl(opts) {
|
|
311
|
-
|
|
352
|
+
applyColorMode(); // NO_COLOR (piece 2 §1.2) — idempotent; main() already did it for the CLI path
|
|
353
|
+
// 2026-09-26 — the look must be known before the first startup line prints.
|
|
354
|
+
// A config read failure keeps the default look; the real loadConfig below
|
|
355
|
+
// still runs and reports errors exactly as before.
|
|
356
|
+
try {
|
|
357
|
+
const early = await loadConfig();
|
|
358
|
+
setLook(early.ui.look);
|
|
359
|
+
setBackground(early.ui.background);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
setLook(DEFAULT_CONFIG.ui.look);
|
|
363
|
+
}
|
|
364
|
+
// Piece 2 §10: the account folds into the panels brand line; "!" notices
|
|
365
|
+
// collect here and print last. Owner decision 2026-09-27: the panels brand
|
|
366
|
+
// line prints FIRST (no blank terminal during the sign-in check and the
|
|
367
|
+
// rest of startup); the sign-in completes the same line below. Legacy keeps
|
|
368
|
+
// its pinned banner exactly as before.
|
|
369
|
+
const startupNotices = [];
|
|
370
|
+
if (getLook() !== 'panels')
|
|
371
|
+
printStartupBanner();
|
|
372
|
+
const earlyBrand = getLook() === 'panels'
|
|
373
|
+
? startEarlyBrand({ version: CLI_VERSION, tty: process.stdout.isTTY === true, write: (s) => { process.stdout.write(s); } })
|
|
374
|
+
: undefined;
|
|
312
375
|
// v0.6 — surface interrupted sessions at startup so the developer doesn't
|
|
313
376
|
// have to remember the session id from the crash UX. Like `git stash list`
|
|
314
377
|
// for resilience: if you have any unfinished work, you see it immediately.
|
|
@@ -318,8 +381,10 @@ export async function runRepl(opts) {
|
|
|
318
381
|
const { countInterruptedSessions } = await import('./session/store.js');
|
|
319
382
|
const n = await countInterruptedSessions();
|
|
320
383
|
if (n > 0) {
|
|
321
|
-
|
|
322
|
-
|
|
384
|
+
if (getLook() === 'panels')
|
|
385
|
+
startupNotices.push(panelsInterruptedNote(n) + '\n');
|
|
386
|
+
else
|
|
387
|
+
console.log(formatAttention({ text: `You have ${n} interrupted session${n === 1 ? '' : 's'}`, next: 'cspeach --resume (picks the right one from a list)' }));
|
|
323
388
|
}
|
|
324
389
|
}
|
|
325
390
|
catch { /* startup banner is best-effort */ }
|
|
@@ -331,7 +396,7 @@ export async function runRepl(opts) {
|
|
|
331
396
|
// flag is process-scoped, so this prints at most once per CLI launch.
|
|
332
397
|
try {
|
|
333
398
|
const { isFlagOn } = await import('./agent/maybe-build-project-context.js');
|
|
334
|
-
if (isFlagOn()) {
|
|
399
|
+
if (isFlagOn() && getLook() !== 'panels') {
|
|
335
400
|
console.log(chalk.cyan(`[project-context] enabled (cwd=${process.cwd()})`));
|
|
336
401
|
console.log(chalk.dim(' unset CSPEACH_PROJECT_CONTEXT to disable; run `cspeach analyze project-context` later to compare.\n'));
|
|
337
402
|
}
|
|
@@ -341,6 +406,7 @@ export async function runRepl(opts) {
|
|
|
341
406
|
// and render the verbose login banner. Falls through to the legacy keytar
|
|
342
407
|
// flow below when the file is absent (back-compat for pre-`cspeach login`
|
|
343
408
|
// users).
|
|
409
|
+
let signIn;
|
|
344
410
|
try {
|
|
345
411
|
const authFile = await readAuthFile();
|
|
346
412
|
if (authFile) {
|
|
@@ -348,25 +414,44 @@ export async function runRepl(opts) {
|
|
|
348
414
|
const meResult = await fetchMe({ proxyUrl: cfgEarly.proxy_url, apiKey: authFile.apiKey });
|
|
349
415
|
if (!meResult.ok) {
|
|
350
416
|
if (meResult.status === 401) {
|
|
351
|
-
|
|
417
|
+
earlyBrand?.close();
|
|
418
|
+
console.log(formatFailure({ what: 'Your CSPeach sign-in is no longer valid', next: 'cspeach login' }));
|
|
352
419
|
return 1;
|
|
353
420
|
}
|
|
354
|
-
|
|
421
|
+
const note = formatInfo(`Could not refresh your account (${meResult.error})`);
|
|
422
|
+
if (getLook() === 'panels')
|
|
423
|
+
startupNotices.push(note);
|
|
424
|
+
else
|
|
425
|
+
console.log(note.trimEnd());
|
|
355
426
|
}
|
|
356
427
|
else {
|
|
357
428
|
const banner = formatLoginBanner(meResult.profile);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
429
|
+
if (getLook() === 'panels') {
|
|
430
|
+
signIn = signInFragment(meResult.profile);
|
|
431
|
+
if (banner.warningLine)
|
|
432
|
+
startupNotices.push(formatAttention({ text: banner.warningLine }));
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
console.log(banner.line);
|
|
436
|
+
if (banner.warningLine)
|
|
437
|
+
console.log(formatAttention({ text: banner.warningLine }).trimEnd());
|
|
438
|
+
}
|
|
361
439
|
}
|
|
362
440
|
}
|
|
363
441
|
}
|
|
364
442
|
catch { /* banner is best-effort — never block REPL startup */ }
|
|
443
|
+
earlyBrand?.finish(signIn);
|
|
365
444
|
const store = await getStore();
|
|
366
|
-
|
|
367
|
-
printKeytarFallbackBanner();
|
|
368
|
-
}
|
|
445
|
+
const keychainMissing = !(await store.isKeychainAvailable());
|
|
369
446
|
let apiKey = await store.get();
|
|
447
|
+
if (keychainMissing) {
|
|
448
|
+
// Panels: a "!" line with the other notices — unless the API key prompt is
|
|
449
|
+
// about to open, which needs the explanation right now.
|
|
450
|
+
if (getLook() === 'panels' && apiKey)
|
|
451
|
+
startupNotices.push(keychainFallbackNotice());
|
|
452
|
+
else
|
|
453
|
+
printKeytarFallbackBanner();
|
|
454
|
+
}
|
|
370
455
|
if (!apiKey) {
|
|
371
456
|
const entered = await promptPassword({
|
|
372
457
|
message: 'Paste your CSPeach API key (from invite email):',
|
|
@@ -376,19 +461,40 @@ export async function runRepl(opts) {
|
|
|
376
461
|
apiKey = entered;
|
|
377
462
|
}
|
|
378
463
|
let cfg = await loadConfig();
|
|
379
|
-
|
|
380
|
-
|
|
464
|
+
setLook(cfg.ui.look);
|
|
465
|
+
// Apply the rendering mode BEFORE any shouldUseInk() call. This is the
|
|
466
|
+
// interactive entry point, so it is never one-shot whatever argv holds. A
|
|
467
|
+
// stored `classic` is retiring: read as auto, rewritten once, one dim notice
|
|
468
|
+
// (CSPEACH_UI=classic is the escape hatch) — see renderer/rendering-mode.ts.
|
|
469
|
+
setOneShotOverride(false);
|
|
470
|
+
const classicRetiringNotice = await applyStartupRendering(cfg, saveConfig);
|
|
471
|
+
if (classicRetiringNotice) {
|
|
472
|
+
if (getLook() === 'panels')
|
|
473
|
+
startupNotices.push(classicRetiringNotice);
|
|
474
|
+
else
|
|
475
|
+
process.stdout.write(classicRetiringNotice);
|
|
476
|
+
}
|
|
477
|
+
setBackground(cfg.ui.background);
|
|
381
478
|
// Standalone (2026-09-16) — run with no SAP system at all. Decided BEFORE
|
|
382
479
|
// the onboarding wizard: someone who asked for --no-sap (or persisted
|
|
383
480
|
// no_sap) must never be pushed through the SAP setup they don't have.
|
|
384
481
|
// Having aliases configured does NOT override the request.
|
|
385
482
|
// A session that WAS standalone stays standalone on `--resume`: its
|
|
386
483
|
// sap_system is the reserved 'none' alias, which has no entry in cfg.sap.
|
|
387
|
-
|
|
388
|
-
|
|
484
|
+
// Round 6 item 2 — …unless an explicit --sap-alias asks for a SAP system:
|
|
485
|
+
// then the flag wins and the resumed session moves to it.
|
|
486
|
+
const standaloneDecision = resumeStandaloneDecision({
|
|
487
|
+
requestedStandalone: resolveStandalone(cfg, opts?.noSap === true),
|
|
488
|
+
resumedSapSystem: opts?.resumedSession?.sap_system,
|
|
489
|
+
sapAlias: opts?.sapAlias,
|
|
490
|
+
standaloneSource: opts?.noSap === true ? '--no-sap' : 'no_sap setting',
|
|
491
|
+
});
|
|
492
|
+
let standalone = standaloneDecision.standalone;
|
|
389
493
|
const enterStandalone = async () => {
|
|
390
|
-
|
|
391
|
-
'
|
|
494
|
+
if (getLook() !== 'panels') {
|
|
495
|
+
console.log(chalk.dim('\nStandalone mode: no SAP system connected — skills work from pasted code. ' +
|
|
496
|
+
'Connect later with: cspeach config add-sap'));
|
|
497
|
+
}
|
|
392
498
|
// Ask the profile questions ONCE — they become the <sap_system> element
|
|
393
499
|
// every skill reads, so without them a design would target "unknown".
|
|
394
500
|
if (!cfg.standalone) {
|
|
@@ -444,12 +550,20 @@ export async function runRepl(opts) {
|
|
|
444
550
|
// identity, so print here exactly as before (byte-identical output); when
|
|
445
551
|
// any alias declares a role, defer the banner until the alias is resolved.
|
|
446
552
|
const anyRoleConfigured = Object.values(cfg.sap).some((s) => s.role !== undefined);
|
|
553
|
+
const advisoryNotice = (capped) => formatAttention({
|
|
554
|
+
text: 'Write mode: advisory',
|
|
555
|
+
fact: `writes are proposed, never executed${capped}`,
|
|
556
|
+
// The config hint only makes sense when config CAUSED advisory.
|
|
557
|
+
next: capped === '' ? 'cspeach config set write_mode approval-gated' : undefined,
|
|
558
|
+
});
|
|
447
559
|
if (!anyRoleConfigured) {
|
|
448
560
|
if (cfg.write_mode === 'advisory-only') {
|
|
449
|
-
|
|
450
|
-
|
|
561
|
+
if (getLook() === 'panels')
|
|
562
|
+
startupNotices.push(advisoryNotice(''));
|
|
563
|
+
else
|
|
564
|
+
console.log(advisoryNotice(''));
|
|
451
565
|
}
|
|
452
|
-
else {
|
|
566
|
+
else if (getLook() !== 'panels') {
|
|
453
567
|
console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
|
|
454
568
|
}
|
|
455
569
|
}
|
|
@@ -460,15 +574,28 @@ export async function runRepl(opts) {
|
|
|
460
574
|
// visibly: the toggle is persisted, not re-consented each session, so the user
|
|
461
575
|
// must see that local file access + command execution are live this session.
|
|
462
576
|
// Printed to stdout here (before any Ink frame mounts).
|
|
577
|
+
// 2026-09-26 — panels folds this announce into the one status line below.
|
|
578
|
+
// 2026-09-26 (I1) — the panels status line must say WHICH tools are live:
|
|
579
|
+
// local_build = file tools + shell command execution; standalone alone =
|
|
580
|
+
// file tools writing to ./cspeach-out.
|
|
581
|
+
let localTools = 'none';
|
|
463
582
|
{
|
|
464
583
|
const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
584
|
+
localTools = announce ? 'files+commands' : standalone ? 'files' : 'none';
|
|
585
|
+
if (getLook() === 'panels') {
|
|
586
|
+
// The side effect (turning the file tools on) must still happen.
|
|
587
|
+
if (!announce && standalone)
|
|
588
|
+
applyStandaloneFileTools(process.cwd());
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
if (announce)
|
|
592
|
+
console.log(chalk.dim(announce + '\n'));
|
|
593
|
+
// 2026-09-17 — standalone writes its outputs to files by contract, so the
|
|
594
|
+
// file tools must be on without a separate local_build step (the RAP
|
|
595
|
+
// stack landed in the transcript with "There are no files" otherwise).
|
|
596
|
+
else if (standalone)
|
|
597
|
+
console.log(chalk.dim(applyStandaloneFileTools(process.cwd()) + '\n'));
|
|
598
|
+
}
|
|
472
599
|
}
|
|
473
600
|
const aliases = Object.keys(cfg.sap);
|
|
474
601
|
// C.1 — --sap-alias flag override skips the interactive picker
|
|
@@ -479,13 +606,17 @@ export async function runRepl(opts) {
|
|
|
479
606
|
selectedAlias = STANDALONE_ALIAS;
|
|
480
607
|
}
|
|
481
608
|
else if (opts?.sapAlias) {
|
|
482
|
-
|
|
609
|
+
const note = aliasSourceNote('flag', opts.sapAlias);
|
|
610
|
+
if (note !== null)
|
|
611
|
+
console.log(note);
|
|
483
612
|
selectedAlias = opts.sapAlias;
|
|
484
613
|
}
|
|
485
614
|
else if (opts?.resumedSession?.sap_system) {
|
|
486
615
|
// v0.6 — resumed session knows its SAP. Skip the picker.
|
|
487
616
|
selectedAlias = opts.resumedSession.sap_system;
|
|
488
|
-
|
|
617
|
+
const note = aliasSourceNote('resume', selectedAlias);
|
|
618
|
+
if (note !== null)
|
|
619
|
+
console.log(note);
|
|
489
620
|
}
|
|
490
621
|
else {
|
|
491
622
|
selectedAlias =
|
|
@@ -507,25 +638,35 @@ export async function runRepl(opts) {
|
|
|
507
638
|
? ` (capped by sap.${selectedAlias}.role = ${aliasRole})`
|
|
508
639
|
: '';
|
|
509
640
|
if (startupWriteMode === 'advisory-only') {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
console.log(
|
|
515
|
-
}
|
|
516
|
-
else {
|
|
517
|
-
console.log('');
|
|
518
|
-
}
|
|
641
|
+
// A role-capped advisory can't be lifted by a config change (no Next:).
|
|
642
|
+
if (getLook() === 'panels')
|
|
643
|
+
startupNotices.push(advisoryNotice(capped));
|
|
644
|
+
else
|
|
645
|
+
console.log(advisoryNotice(capped));
|
|
519
646
|
}
|
|
520
|
-
else {
|
|
647
|
+
else if (getLook() !== 'panels') {
|
|
521
648
|
console.log(chalk.gray(`Write mode: ${startupWriteMode}${capped}\n`));
|
|
522
649
|
}
|
|
523
650
|
}
|
|
524
651
|
// Standalone: the offline stand-in rejects every call with an actionable
|
|
525
652
|
// message. This is the single construction site the AdtClient cast lives at.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
653
|
+
let adt;
|
|
654
|
+
if (standalone) {
|
|
655
|
+
adt = createOfflineAdtClient();
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
try {
|
|
659
|
+
adt = new AdtClient(await getConnection(selectedAlias, cfg.sap[selectedAlias]));
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
// Dry run 2026-09-25: unreachable SAP host → one clear line, not a stack.
|
|
663
|
+
const code = unreachableCode(err);
|
|
664
|
+
if (!code)
|
|
665
|
+
throw err;
|
|
666
|
+
console.error(unreachableMessage(selectedAlias, cfg.sap[selectedAlias].host, cfg.sap[selectedAlias].port, code));
|
|
667
|
+
return 1;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
529
670
|
// Phase A #3 (2026-05-16) — sidebar SAP-state wire, trigger 1 of 4.
|
|
530
671
|
// Push system + user the moment we know the alias. Before this commit
|
|
531
672
|
// the sidebar showed "no system / user: —" forever because the store
|
|
@@ -541,15 +682,38 @@ export async function runRepl(opts) {
|
|
|
541
682
|
system: standalone ? 'standalone' : selectedAlias,
|
|
542
683
|
user: standalone ? '' : cfg.sap[selectedAlias].username,
|
|
543
684
|
writeMode: startupWriteMode,
|
|
685
|
+
standalone,
|
|
544
686
|
});
|
|
545
687
|
// v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
|
|
546
688
|
// preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
|
|
547
689
|
// turnInterrupted flag from the prior run. Skipping newSession() avoids
|
|
548
690
|
// overwriting the saved file with an empty fresh one.
|
|
549
691
|
const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg));
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
692
|
+
// Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
|
|
693
|
+
let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
|
|
694
|
+
// Round 7 item 8c — the footer shows a resumed session's spend at once.
|
|
695
|
+
if (opts?.resumedSession)
|
|
696
|
+
seedFooterSpend(session);
|
|
697
|
+
// Round 6 item 4b — a new session is written by its first turn (the loop
|
|
698
|
+
// saves after every round); a start-then-quit no longer leaves a 0-turn file.
|
|
699
|
+
// Round 6 item 2 + review I3 — an explicit --sap-alias moved a resumed
|
|
700
|
+
// session (standalone → SAP, or to another SAP system): update + save it.
|
|
701
|
+
await applySystemSwitch({
|
|
702
|
+
session,
|
|
703
|
+
decision: { ...standaloneDecision, standalone },
|
|
704
|
+
selectedAlias,
|
|
705
|
+
save: saveSession,
|
|
706
|
+
notify: (line) => {
|
|
707
|
+
if (getLook() === 'panels')
|
|
708
|
+
startupNotices.push(line);
|
|
709
|
+
else
|
|
710
|
+
console.log(line.trimEnd());
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
// Live fix 2026-09-26 — notices cut to one screen line keep their full text here.
|
|
714
|
+
// Review M6: sanitised id; notice logs older than 14 days are collected.
|
|
715
|
+
pruneNoticeLogs(logsDir());
|
|
716
|
+
setNoticeLogPath(noticeLogPathFor(logsDir(), session.id));
|
|
553
717
|
// License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
|
|
554
718
|
// inference, so without this anyone who never logged in could use the bundled
|
|
555
719
|
// skills untracked. Require a valid CSPeach login before the REPL starts.
|
|
@@ -569,30 +733,63 @@ export async function runRepl(opts) {
|
|
|
569
733
|
throw new Error('No API key configured');
|
|
570
734
|
return key;
|
|
571
735
|
});
|
|
736
|
+
// Panels status line — writeMode is the same EFFECTIVE (role-clamped) mode
|
|
737
|
+
// the legacy line prints.
|
|
738
|
+
const panelsStatus = {
|
|
739
|
+
system: selectedAlias,
|
|
740
|
+
standalone,
|
|
741
|
+
tools: localTools,
|
|
742
|
+
writeMode: startupWriteMode,
|
|
743
|
+
cwd: process.cwd(),
|
|
744
|
+
};
|
|
572
745
|
if (opts?.resumedSession) {
|
|
573
746
|
const turnCount = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
747
|
+
// Task 6 (ux-wave2) — session recap (last session · open todos ·
|
|
748
|
+
// unsatisfied plan) so the returning developer is oriented without
|
|
749
|
+
// scrolling. Covers BOTH resume entries — the `cspeach --resume` picker
|
|
750
|
+
// (session/resume.ts) and any direct --resume <id> — since both land here
|
|
751
|
+
// with opts.resumedSession set. Best-effort: the recap never throws, and a
|
|
752
|
+
// workspace-resolution failure just drops the plan fact.
|
|
753
|
+
if (getLook() === 'panels') {
|
|
754
|
+
let recap = { last: null, tasks: null, plan: null };
|
|
755
|
+
try {
|
|
756
|
+
const { buildRecapParts } = await import('./session/recap.js');
|
|
757
|
+
const { resolveWorkspacePath } = await import('./projects/workspace.js');
|
|
758
|
+
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
759
|
+
recap = buildRecapParts(session, process.cwd(), { workspaceDir: recapWs });
|
|
760
|
+
}
|
|
761
|
+
catch { /* recap is best-effort — never block resume */ }
|
|
762
|
+
// Piece 2 §10: same three-line shape. Open tasks show in the checklist
|
|
763
|
+
// (restored from session.todos), so only the plan becomes a "!" line.
|
|
764
|
+
if (session.turnInterrupted) {
|
|
765
|
+
// Round 7 item 8d — a stop the user asked for is not an error.
|
|
766
|
+
startupNotices.push(isUserStopReason(session.turnInterruptedReason)
|
|
767
|
+
? formatAttention({ text: 'Last turn was stopped by you', next: 'type continue to finish where it left off' })
|
|
768
|
+
: formatAttention({ text: 'Last turn was interrupted', fact: readableInterruptReason(session.turnInterruptedReason), next: 'type continue to finish where it left off' }));
|
|
769
|
+
}
|
|
770
|
+
if (recap.plan)
|
|
771
|
+
startupNotices.push(formatAttention({ text: `Unfinished plan ${recap.plan.text}`, next: recap.plan.next }));
|
|
772
|
+
process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsResumeLine(recap.last, turnCount), notices: startupNotices }));
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
console.log(chalk.cyan(`\nResumed session ${session.id} on ${selectedAlias} — ${turnCount} prior turn(s).`));
|
|
776
|
+
if (session.turnInterrupted) {
|
|
777
|
+
console.log(formatAttention({ text: isUserStopReason(session.turnInterruptedReason) ? 'Last turn was stopped by you' : `Last turn was interrupted (${readableInterruptReason(session.turnInterruptedReason)})`, next: 'type "continue" to ask the model to finish where it left off' }).trimEnd());
|
|
592
778
|
}
|
|
779
|
+
try {
|
|
780
|
+
const { buildRecap } = await import('./session/recap.js');
|
|
781
|
+
const { resolveWorkspacePath } = await import('./projects/workspace.js');
|
|
782
|
+
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
783
|
+
for (const recapLine of buildRecap(session, process.cwd(), { workspaceDir: recapWs })) {
|
|
784
|
+
console.log(chalk.dim(recapLine));
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch { /* recap is best-effort — never block resume */ }
|
|
788
|
+
console.log(chalk.gray(`Type /exit to quit.\n`));
|
|
593
789
|
}
|
|
594
|
-
|
|
595
|
-
|
|
790
|
+
}
|
|
791
|
+
else if (getLook() === 'panels') {
|
|
792
|
+
process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools), notices: startupNotices }));
|
|
596
793
|
}
|
|
597
794
|
else {
|
|
598
795
|
console.log(chalk.gray(`\nSession ${session.id} on ${selectedAlias}. Type /exit to quit.\n`));
|
|
@@ -625,6 +822,8 @@ export async function runRepl(opts) {
|
|
|
625
822
|
const { App } = await import('./ui/app.js');
|
|
626
823
|
const React = (await import('react')).default;
|
|
627
824
|
const chunkEmitter = new EventEmitter();
|
|
825
|
+
setTraceEmitter(chunkEmitter); // piece 2 §1.5 — answered/decided traces land in <Static>
|
|
826
|
+
const inkPreview = inkPreviewHook(chunkEmitter); // piece 2 §6.3 — Ink sites: diff to scrollback + Apply/Cancel card
|
|
628
827
|
// Phase D6 (2026-05-17) — INLINE Ink mode (no alt-screen).
|
|
629
828
|
//
|
|
630
829
|
// After Laeeq pointed out CC achieves great scrolling without any
|
|
@@ -674,6 +873,50 @@ export async function runRepl(opts) {
|
|
|
674
873
|
// it. When null, we're at idle and should exit on Ctrl+C.
|
|
675
874
|
let currentTurnAbort = null;
|
|
676
875
|
let interruptCount = 0;
|
|
876
|
+
// Review I2/I3 — a cancel asked for before the turn has its controller
|
|
877
|
+
// (routing / prep). Applied at controller creation; cleared when the
|
|
878
|
+
// dispatch ends without a turn.
|
|
879
|
+
const pendingCancel = createPendingTurnCancel();
|
|
880
|
+
// Re-review 2/3 — where a busy dispatch without a controller is, so the
|
|
881
|
+
// cancel line says only what will actually happen. postTurn: the turn's
|
|
882
|
+
// controller has been cleared (saving, audit, next-phase offer);
|
|
883
|
+
// postTurnMayChain: that post-turn work may auto-start a plan phase.
|
|
884
|
+
let postTurn = false;
|
|
885
|
+
let postTurnMayChain = false;
|
|
886
|
+
let cancelAnnouncedPhase = null;
|
|
887
|
+
/** Round 4 item 5 — the in-flight classifier request, while routing. */
|
|
888
|
+
let routingAbort = null;
|
|
889
|
+
const cancelPhase = () => turnStatusEmitter.snapshot().routing ? 'routing' : postTurn ? 'post' : 'prep';
|
|
890
|
+
const canChainNow = () => postTurnMayChain || pendingDispatchAccessor.get() !== null;
|
|
891
|
+
/** Queue a cancel; print its one truthful line the first time. */
|
|
892
|
+
const announcePendingCancel = (exiting) => {
|
|
893
|
+
if (!pendingCancel.request())
|
|
894
|
+
return;
|
|
895
|
+
const phase = cancelPhase();
|
|
896
|
+
cancelAnnouncedPhase = phase;
|
|
897
|
+
// Round 4 item 5 — stop the classifier request (and its spinner) now.
|
|
898
|
+
routingAbort?.abort();
|
|
899
|
+
const line = phase === 'post' && !canChainNow()
|
|
900
|
+
? 'Exiting when this turn has finished saving'
|
|
901
|
+
// Round 4 item 9a — name what is queued (a skill switch is not a plan phase).
|
|
902
|
+
: pendingCancelLine(phase, exiting, queuedWorkLabel(pendingDispatchAccessor.get()));
|
|
903
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo(line));
|
|
904
|
+
};
|
|
905
|
+
/** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
|
|
906
|
+
const autoDispatch = (cmd) => {
|
|
907
|
+
// Round 4 item 9b — the exit is already under way: start nothing new.
|
|
908
|
+
// Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
|
|
909
|
+
const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending }, cmd);
|
|
910
|
+
if (decision.kind === 'skip')
|
|
911
|
+
return;
|
|
912
|
+
if (decision.kind === 'not-started') {
|
|
913
|
+
pendingCancel.clear();
|
|
914
|
+
chunkEmitter.emit('chunk', formatInfo(decision.line));
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
chunkEmitter.emit('chunk', formatInfo(`Auto-routed: ${cmd}`));
|
|
918
|
+
setImmediate(() => { void handleSubmit(cmd); });
|
|
919
|
+
};
|
|
677
920
|
// BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
|
|
678
921
|
// owns the single-flight reserve()/release() lifecycle. All re-entrant
|
|
679
922
|
// callers (the setImmediate auto-dispatch / re-audit legs) go through the
|
|
@@ -682,6 +925,10 @@ export async function runRepl(opts) {
|
|
|
682
925
|
const trimmed = line.trim();
|
|
683
926
|
if (!trimmed)
|
|
684
927
|
return;
|
|
928
|
+
// Round 6 review I2 — the resumed skill is offered to the FIRST submit
|
|
929
|
+
// only (slash commands included), then cleared.
|
|
930
|
+
const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
|
|
931
|
+
resumeKeepsSkill = false;
|
|
685
932
|
// Task 8 (ux-wave1) — shared dedup-guarded writer. On the Ink path
|
|
686
933
|
// the Footer has already persisted this submission (flattened), so
|
|
687
934
|
// the consecutive-dup guard makes this a no-op instead of the old
|
|
@@ -694,8 +941,7 @@ export async function runRepl(opts) {
|
|
|
694
941
|
// new turn's content touched with no separator and looked like
|
|
695
942
|
// garbled overlap).
|
|
696
943
|
if (trimmed !== '/exit' && trimmed !== '/quit') {
|
|
697
|
-
chunkEmitter.emit('chunk',
|
|
698
|
-
chalk.cyan(`› ${trimmed}\n`));
|
|
944
|
+
chunkEmitter.emit('chunk', formatPromptEcho(trimmed, getEffectiveBodyWidth()));
|
|
699
945
|
}
|
|
700
946
|
if (trimmed === '/exit' || trimmed === '/quit') {
|
|
701
947
|
done = true;
|
|
@@ -719,7 +965,9 @@ export async function runRepl(opts) {
|
|
|
719
965
|
// from the JSONL log written each turn. Read-only, no network.
|
|
720
966
|
if (trimmed === '/cost') {
|
|
721
967
|
const { runCostCommand } = await import('./commands/cost.js');
|
|
722
|
-
|
|
968
|
+
// Piece 2: through the chunkEmitter (like /context and /export) — a
|
|
969
|
+
// console.log here would land above the Ink frame.
|
|
970
|
+
await runCostCommand(session.id, undefined, (l) => chunkEmitter.emit('chunk', l + '\n'));
|
|
723
971
|
return;
|
|
724
972
|
}
|
|
725
973
|
// Task 4 (ux-wave3) — /export audit [filename]: write the signable
|
|
@@ -764,7 +1012,7 @@ export async function runRepl(opts) {
|
|
|
764
1012
|
if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
|
|
765
1013
|
const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
|
|
766
1014
|
const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
|
|
767
|
-
chunkEmitter.emit('chunk',
|
|
1015
|
+
chunkEmitter.emit('chunk', formatInfo(modeLine));
|
|
768
1016
|
sapStateStore.update({ writeMode: mode });
|
|
769
1017
|
return;
|
|
770
1018
|
}
|
|
@@ -777,8 +1025,8 @@ export async function runRepl(opts) {
|
|
|
777
1025
|
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
778
1026
|
const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
|
|
779
1027
|
const body = recapLines.length === 0
|
|
780
|
-
?
|
|
781
|
-
: recapLines.map((l) =>
|
|
1028
|
+
? formatInfo('Nothing to recap yet').trimEnd()
|
|
1029
|
+
: formatPanel('Recap', recapLines.map((l) => ' ' + paint('dim', sanitizeControlChars(l)))).join('\n');
|
|
782
1030
|
chunkEmitter.emit('chunk', '\n' + body + '\n');
|
|
783
1031
|
return;
|
|
784
1032
|
}
|
|
@@ -797,12 +1045,12 @@ export async function runRepl(opts) {
|
|
|
797
1045
|
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
798
1046
|
};
|
|
799
1047
|
try {
|
|
800
|
-
chunkEmitter.emit('chunk',
|
|
1048
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Compacting older turns into a summary…'));
|
|
801
1049
|
const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
|
|
802
1050
|
chunkEmitter.emit('chunk', formatCompactResult(result, compactCfg.keep_recent_turns));
|
|
803
1051
|
}
|
|
804
1052
|
catch (err) {
|
|
805
|
-
chunkEmitter.emit('chunk',
|
|
1053
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Compact failed', detail: [err instanceof Error ? err.message : String(err)] }));
|
|
806
1054
|
}
|
|
807
1055
|
return;
|
|
808
1056
|
}
|
|
@@ -812,9 +1060,9 @@ export async function runRepl(opts) {
|
|
|
812
1060
|
if (trimmed === '/files') {
|
|
813
1061
|
try {
|
|
814
1062
|
const { openFilePicker } = await import('./repl/file-picker.js');
|
|
815
|
-
const result = await openFilePicker();
|
|
1063
|
+
const result = await openFilePicker({ ink: true });
|
|
816
1064
|
if (result.kind === 'command') {
|
|
817
|
-
chunkEmitter.emit('chunk',
|
|
1065
|
+
chunkEmitter.emit('chunk', formatInfo(result.command));
|
|
818
1066
|
}
|
|
819
1067
|
else if (result.kind === 'empty') {
|
|
820
1068
|
// Empty workspace — print the listing so the user sees the
|
|
@@ -828,7 +1076,7 @@ export async function runRepl(opts) {
|
|
|
828
1076
|
// result.kind === 'cancelled' — print nothing.
|
|
829
1077
|
}
|
|
830
1078
|
catch (err) {
|
|
831
|
-
chunkEmitter.emit('chunk',
|
|
1079
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not open the file list', detail: [err.message] }));
|
|
832
1080
|
}
|
|
833
1081
|
return;
|
|
834
1082
|
}
|
|
@@ -837,32 +1085,12 @@ export async function runRepl(opts) {
|
|
|
837
1085
|
// lands in Body, not above the React frame.
|
|
838
1086
|
if (trimmed === '/ui' || trimmed.startsWith('/ui ')) {
|
|
839
1087
|
if (!cfg.ui)
|
|
840
|
-
cfg.ui = { rendering: 'auto' };
|
|
1088
|
+
cfg.ui = { rendering: 'auto', look: DEFAULT_CONFIG.ui.look };
|
|
841
1089
|
const arg = trimmed.slice(3).trim().toLowerCase();
|
|
842
1090
|
const emit = (s) => chunkEmitter.emit('chunk', s + '\n');
|
|
843
|
-
if (arg
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
`(effective now: ${chalk.yellow(shouldUseInk() ? 'ink' : 'classic')})`);
|
|
847
|
-
emit(chalk.dim(' /ui classic — chalk REPL (default)'));
|
|
848
|
-
emit(chalk.dim(' /ui ink — Ink frame (opt-in)'));
|
|
849
|
-
emit(chalk.dim(' /ui auto — choose automatically'));
|
|
850
|
-
emit(chalk.dim('Change takes effect after /exit + relaunch.'));
|
|
851
|
-
}
|
|
852
|
-
else if (arg === 'auto' || arg === 'ink' || arg === 'classic') {
|
|
853
|
-
cfg.ui.rendering = arg;
|
|
854
|
-
try {
|
|
855
|
-
await saveConfig(cfg);
|
|
856
|
-
emit(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
|
|
857
|
-
emit(chalk.dim('Relaunch cspeach to apply.'));
|
|
858
|
-
}
|
|
859
|
-
catch (err) {
|
|
860
|
-
emit(chalk.red(`\n✗ Failed to save config: ${err.message}`));
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
else {
|
|
864
|
-
emit(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.`));
|
|
865
|
-
}
|
|
1091
|
+
if (await handleUiLook(arg, cfg, saveConfig, emit))
|
|
1092
|
+
return;
|
|
1093
|
+
await handleUiRendering(arg, cfg, saveConfig, emit, shouldUseInk() ? 'ink' : 'classic');
|
|
866
1094
|
return;
|
|
867
1095
|
}
|
|
868
1096
|
// /reroute — re-dispatch session.lastUserPrompt to a different skill
|
|
@@ -874,23 +1102,41 @@ export async function runRepl(opts) {
|
|
|
874
1102
|
return;
|
|
875
1103
|
}
|
|
876
1104
|
const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
|
|
877
|
-
chunkEmitter.emit('chunk',
|
|
1105
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo(`Rerouting "${preview}" to /${rr.skill}`));
|
|
878
1106
|
if (rr.warning)
|
|
879
1107
|
chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
|
|
1108
|
+
// Re-review 1 — its own AbortController, wired like the main path, so
|
|
1109
|
+
// Ctrl+C and `/exit` stop a rerouted turn the same way.
|
|
1110
|
+
const rerouteAbort = new AbortController();
|
|
1111
|
+
currentTurnAbort = rerouteAbort;
|
|
1112
|
+
interruptCount = 0;
|
|
1113
|
+
if (pendingCancel.applyTo(rerouteAbort))
|
|
1114
|
+
interruptCount = 1;
|
|
880
1115
|
turnStatusEmitter.start(`/${rr.skill}`);
|
|
881
|
-
|
|
882
|
-
|
|
1116
|
+
// A turn error is rendered into the conversation like the normal turn
|
|
1117
|
+
// path (never an unhandled rejection out of `void handleSubmit`).
|
|
1118
|
+
await runRerouteTurn({
|
|
1119
|
+
run: () => runTurn({
|
|
883
1120
|
provider,
|
|
884
1121
|
userMessage: rr.prompt,
|
|
885
1122
|
skill: rr.skill,
|
|
886
|
-
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook:
|
|
1123
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
887
1124
|
chunkEmitter,
|
|
888
1125
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1126
|
+
signal: rerouteAbort.signal,
|
|
1127
|
+
}),
|
|
1128
|
+
session,
|
|
1129
|
+
print: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1130
|
+
signal: rerouteAbort.signal,
|
|
1131
|
+
onFinally: () => {
|
|
1132
|
+
turnStatusEmitter.stop();
|
|
1133
|
+
currentTurnAbort = null;
|
|
1134
|
+
postTurn = true;
|
|
1135
|
+
// P2 fix round 1 — /reroute skips pushPostTurnStatus, so run its
|
|
1136
|
+
// turn-end plan step here (close line / 'close' when finished).
|
|
1137
|
+
emitPlanTurnEnd(session);
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
894
1140
|
return;
|
|
895
1141
|
}
|
|
896
1142
|
// /new — clear any "awaiting skill answer" state so the next prompt
|
|
@@ -898,7 +1144,11 @@ export async function runRepl(opts) {
|
|
|
898
1144
|
// previous skill's Q&A chain.
|
|
899
1145
|
if (trimmed === '/new' || trimmed === '/reset') {
|
|
900
1146
|
session.awaitingSkillAnswer = false;
|
|
901
|
-
|
|
1147
|
+
// P2 — /new clears the plan checklist (spec §4 lifecycle): the session
|
|
1148
|
+
// list AND the live view, so Ctrl+T / --resume can't bring it back.
|
|
1149
|
+
session.todos = [];
|
|
1150
|
+
todoEmitter.emit('update', []);
|
|
1151
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Fresh start · the next prompt picks a skill again'));
|
|
902
1152
|
return;
|
|
903
1153
|
}
|
|
904
1154
|
// /transport — session current-transport state for Rule 9 (transport isolation)
|
|
@@ -990,63 +1240,119 @@ export async function runRepl(opts) {
|
|
|
990
1240
|
chalk.dim('Type a new question, /new to reset, or /exit to quit.\n'));
|
|
991
1241
|
return;
|
|
992
1242
|
}
|
|
993
|
-
|
|
994
|
-
|
|
1243
|
+
// Round 6 item 4c / review I2 — after a resume, a short follow-up
|
|
1244
|
+
// ("continue", a number) stays in the session's skill; anything else
|
|
1245
|
+
// is classified (applyResumedSkill below).
|
|
1246
|
+
if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
|
|
1247
|
+
chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: session.skill }));
|
|
995
1248
|
skill = session.skill;
|
|
996
1249
|
}
|
|
997
1250
|
else if (isBareAck(trimmed)) {
|
|
998
1251
|
// Bare "y" / "n" / "cancel" with no skill to continue — typing this
|
|
999
1252
|
// after an aborted turn is almost always a stray keystroke, NOT a
|
|
1000
1253
|
// request to start work. Don't burn a classifier round-trip on it.
|
|
1001
|
-
chunkEmitter.emit('chunk',
|
|
1254
|
+
chunkEmitter.emit('chunk', formatInfo('No active skill · type /help or a full prompt'));
|
|
1002
1255
|
return;
|
|
1003
1256
|
}
|
|
1004
1257
|
else {
|
|
1005
1258
|
try {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1259
|
+
// Fix round 3 (item 8) — the status row reads "routing…" for
|
|
1260
|
+
// the whole classifier wait (was a blank screen for up to 16 s).
|
|
1261
|
+
turnStatusEmitter.routingStart();
|
|
1262
|
+
// Round 4 item 5 — Ctrl+C during routing aborts the classifier
|
|
1263
|
+
// request at once (the spinner used to run until it answered,
|
|
1264
|
+
// up to ~17 s). A cancel queued before routing started aborts
|
|
1265
|
+
// it before any request is made.
|
|
1266
|
+
const routingCtl = new AbortController();
|
|
1267
|
+
routingAbort = routingCtl;
|
|
1268
|
+
if (pendingCancel.pending)
|
|
1269
|
+
routingCtl.abort();
|
|
1270
|
+
let c;
|
|
1271
|
+
try {
|
|
1272
|
+
c = await classifyPrompt(prompt, {
|
|
1273
|
+
sap_system: selectedAlias,
|
|
1274
|
+
last_skill: session.last_skill ?? null,
|
|
1275
|
+
last_object: extractedObject ?? session.last_object ?? null,
|
|
1276
|
+
}, { signal: routingCtl.signal });
|
|
1016
1277
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1278
|
+
catch (err) {
|
|
1279
|
+
if (!routingCtl.signal.aborted)
|
|
1280
|
+
throw err;
|
|
1020
1281
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
// questions). session.skill = immediately-previous turn's
|
|
1025
|
-
// skill, NOT session.last_skill which is from TWO turns ago.
|
|
1026
|
-
const continueSkill = session.skill ?? null;
|
|
1027
|
-
let pickedResult;
|
|
1028
|
-
if (cfg.classifier.safe_mode) {
|
|
1029
|
-
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false, continueSkill));
|
|
1282
|
+
finally {
|
|
1283
|
+
routingAbort = null;
|
|
1284
|
+
turnStatusEmitter.routingEnd();
|
|
1030
1285
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1286
|
+
// Review I2/I3 — cancelled while routing: never start the turn.
|
|
1287
|
+
// One line only (re-review 3): the press already printed it when
|
|
1288
|
+
// it came during routing.
|
|
1289
|
+
if (pendingCancel.pending || c === undefined) {
|
|
1290
|
+
pendingCancel.clear();
|
|
1291
|
+
if (cancelAnnouncedPhase !== 'routing') {
|
|
1292
|
+
chunkEmitter.emit('chunk', formatInfo('Turn cancelled · nothing was sent'));
|
|
1293
|
+
}
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (c.screenshot) {
|
|
1297
|
+
chunkEmitter.emit('chunk', formatInfo(`Screenshot: ${c.screenshot}`));
|
|
1034
1298
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1299
|
+
// Fix 10 (2026-09-26): routing failed mid-skill → stay in the
|
|
1300
|
+
// active skill (one dim line), never the picker. Shared decision
|
|
1301
|
+
// with the classic path (routingFailureOutcome).
|
|
1302
|
+
const failed = routingFailureOutcome(routingFailureFromResult(c), session.skill);
|
|
1303
|
+
if (failed.kind === 'stay') {
|
|
1304
|
+
chunkEmitter.emit('chunk', formatInfo(failed.line));
|
|
1305
|
+
skill = failed.skill;
|
|
1037
1306
|
}
|
|
1038
1307
|
else {
|
|
1039
|
-
|
|
1308
|
+
if (c.offline) {
|
|
1309
|
+
// Remote classify failed twice — local-rules fallback fired.
|
|
1310
|
+
// Tell the user so they know the picker preselection came
|
|
1311
|
+
// from regex, not the LLM, and surface the real cause.
|
|
1312
|
+
// Fix 9: true wording from the failure kind (a timeout is not "offline").
|
|
1313
|
+
chunkEmitter.emit('chunk', formatAttention(routingFailureNotice(routingFailureFromResult(c), c.transport_error)));
|
|
1314
|
+
}
|
|
1315
|
+
// Round 6 review I2 — after a resume the classifier's confident pick
|
|
1316
|
+
// wins; the resumed skill replaces only the "Pick a skill" card.
|
|
1317
|
+
const decision = resumedSkill ? applyResumedSkill(decideRouting(c), resumedSkill) : decideRouting(c);
|
|
1318
|
+
// 2026-05-17 — pass the just-used skill so the picker can
|
|
1319
|
+
// default cursor to it (one-Enter "continue" UX for follow-up
|
|
1320
|
+
// questions). session.skill = immediately-previous turn's
|
|
1321
|
+
// skill, NOT session.last_skill which is from TWO turns ago.
|
|
1322
|
+
const continueSkill = session.skill ?? null;
|
|
1323
|
+
let pickedResult;
|
|
1324
|
+
if (cfg.classifier.safe_mode) {
|
|
1325
|
+
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false, continueSkill));
|
|
1326
|
+
}
|
|
1327
|
+
else if (decision.kind === 'silent') {
|
|
1328
|
+
chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'using', skill: decision.skill }));
|
|
1329
|
+
pickedResult = decision.skill;
|
|
1330
|
+
}
|
|
1331
|
+
else if (decision.kind === 'coach') {
|
|
1332
|
+
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, true, continueSkill));
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, false, continueSkill));
|
|
1336
|
+
}
|
|
1337
|
+
if (pickedResult === PICK_CANCELLED) {
|
|
1338
|
+
chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'cancelled' }));
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
skill = pickedResult;
|
|
1342
|
+
} // end fix-10 fallback branch
|
|
1343
|
+
}
|
|
1344
|
+
catch (err) {
|
|
1345
|
+
const failure = routingFailureFromError(err);
|
|
1346
|
+
const failed = routingFailureOutcome(failure, session.skill);
|
|
1347
|
+
if (failed.kind === 'stay') {
|
|
1348
|
+
// Fix 10: HTTP routing failure mid-skill → stay in the active skill.
|
|
1349
|
+
chunkEmitter.emit('chunk', formatInfo(failed.line));
|
|
1350
|
+
skill = failed.skill;
|
|
1040
1351
|
}
|
|
1041
|
-
|
|
1042
|
-
chunkEmitter.emit('chunk',
|
|
1352
|
+
else {
|
|
1353
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: failure ? routingFailureText(failure) : 'Could not pick a skill for this prompt', detail: [err.message], next: 'start the prompt with a skill, e.g. /abap-explain' }));
|
|
1043
1354
|
return;
|
|
1044
1355
|
}
|
|
1045
|
-
skill = pickedResult;
|
|
1046
|
-
}
|
|
1047
|
-
catch (err) {
|
|
1048
|
-
chunkEmitter.emit('chunk', chalk.red(`\n[classifier error] ${err.message}\n`));
|
|
1049
|
-
return;
|
|
1050
1356
|
}
|
|
1051
1357
|
}
|
|
1052
1358
|
}
|
|
@@ -1076,7 +1382,7 @@ export async function runRepl(opts) {
|
|
|
1076
1382
|
});
|
|
1077
1383
|
}
|
|
1078
1384
|
catch (err) {
|
|
1079
|
-
chunkEmitter.emit('chunk',
|
|
1385
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not show the status', detail: [err.message] }));
|
|
1080
1386
|
}
|
|
1081
1387
|
session.last_skill = previousSkill ?? null;
|
|
1082
1388
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
@@ -1108,7 +1414,7 @@ export async function runRepl(opts) {
|
|
|
1108
1414
|
if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
|
|
1109
1415
|
try {
|
|
1110
1416
|
const { preparePlanResume } = await import('./commands/plan-resume.js');
|
|
1111
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1417
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
1112
1418
|
planResume = await preparePlanResume({
|
|
1113
1419
|
body: prompt,
|
|
1114
1420
|
cwd: process.cwd(),
|
|
@@ -1120,11 +1426,12 @@ export async function runRepl(opts) {
|
|
|
1120
1426
|
// resume guard; inkAwarePrompt answers '' in headless = decline).
|
|
1121
1427
|
sessionId: session.id,
|
|
1122
1428
|
prompt: inkAwarePrompt,
|
|
1429
|
+
confirm: inkAwareConfirm,
|
|
1123
1430
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1124
1431
|
});
|
|
1125
1432
|
}
|
|
1126
1433
|
catch (err) {
|
|
1127
|
-
chunkEmitter.emit('chunk',
|
|
1434
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
|
|
1128
1435
|
}
|
|
1129
1436
|
if (!planResume) {
|
|
1130
1437
|
session.last_skill = previousSkill ?? null;
|
|
@@ -1139,7 +1446,7 @@ export async function runRepl(opts) {
|
|
|
1139
1446
|
if (planResume.reauditPhaseId) {
|
|
1140
1447
|
try {
|
|
1141
1448
|
const { runReauditPass } = await import('./commands/plan-chain.js');
|
|
1142
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1449
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
1143
1450
|
await runReauditPass({
|
|
1144
1451
|
savedPath: planResume.path,
|
|
1145
1452
|
phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
|
|
@@ -1152,12 +1459,13 @@ export async function runRepl(opts) {
|
|
|
1152
1459
|
auditMode: planResume.auditMode,
|
|
1153
1460
|
auditFlag: planResume.auditFlag,
|
|
1154
1461
|
prompt: inkAwarePrompt,
|
|
1462
|
+
confirm: inkAwareConfirm,
|
|
1155
1463
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
1156
1464
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1157
1465
|
});
|
|
1158
1466
|
}
|
|
1159
1467
|
catch (err) {
|
|
1160
|
-
chunkEmitter.emit('chunk',
|
|
1468
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan re-audit failed', detail: [err.message] }));
|
|
1161
1469
|
}
|
|
1162
1470
|
session.last_skill = previousSkill ?? null;
|
|
1163
1471
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
@@ -1175,8 +1483,7 @@ export async function runRepl(opts) {
|
|
|
1175
1483
|
}
|
|
1176
1484
|
if (reauditDispatched) {
|
|
1177
1485
|
pendingDispatchAccessor.set(null);
|
|
1178
|
-
|
|
1179
|
-
setImmediate(() => { void handleSubmit(reauditDispatched); });
|
|
1486
|
+
autoDispatch(reauditDispatched);
|
|
1180
1487
|
}
|
|
1181
1488
|
return;
|
|
1182
1489
|
}
|
|
@@ -1188,7 +1495,7 @@ export async function runRepl(opts) {
|
|
|
1188
1495
|
{
|
|
1189
1496
|
const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
|
|
1190
1497
|
if (resetContextForPlanResume(session)) {
|
|
1191
|
-
chunkEmitter.emit('chunk',
|
|
1498
|
+
chunkEmitter.emit('chunk', formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared'));
|
|
1192
1499
|
}
|
|
1193
1500
|
}
|
|
1194
1501
|
planResumeMsgStart = session.messages.length;
|
|
@@ -1244,7 +1551,7 @@ export async function runRepl(opts) {
|
|
|
1244
1551
|
planContinue = { prepared: r.prepared, marker };
|
|
1245
1552
|
planPhaseStartedAt = marker.attemptStartedAt;
|
|
1246
1553
|
planResumeMsgStart = session.messages.length;
|
|
1247
|
-
chunkEmitter.emit('chunk',
|
|
1554
|
+
chunkEmitter.emit('chunk', formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`));
|
|
1248
1555
|
}
|
|
1249
1556
|
else if (r.stale) {
|
|
1250
1557
|
session.plan_in_flight = null; // plan/phase gone for good — marker is noise
|
|
@@ -1271,12 +1578,18 @@ export async function runRepl(opts) {
|
|
|
1271
1578
|
// modes. (Pre-fix this whole block didn't exist in the Ink path —
|
|
1272
1579
|
// sidebar stayed frozen at "0 tok / no turns yet" forever.)
|
|
1273
1580
|
const turnSnapshot = snapshotTurnStart(session);
|
|
1581
|
+
// Round 6 item 4d — the user cancelled this turn (no receipt when it used no tokens).
|
|
1582
|
+
let turnCancelledByUser = false;
|
|
1274
1583
|
// Phase D5 (2026-05-17) — install per-turn abort. onInterrupt
|
|
1275
1584
|
// (Ctrl+C handler) calls currentTurnAbort.abort() while this is
|
|
1276
1585
|
// non-null, which tears the in-flight provider stream down via
|
|
1277
1586
|
// the signal we thread into runTurn.
|
|
1278
1587
|
currentTurnAbort = new AbortController();
|
|
1279
1588
|
interruptCount = 0;
|
|
1589
|
+
// Review I2/I3 — `/exit` or Ctrl+C arrived during prep: stop the turn
|
|
1590
|
+
// at its very start (the press already said "Stopping…").
|
|
1591
|
+
if (pendingCancel.applyTo(currentTurnAbort))
|
|
1592
|
+
interruptCount = 1;
|
|
1280
1593
|
// 2026-06-07 — persistent status row lifecycle. Started here (not in
|
|
1281
1594
|
// loop.ts) so the try/finally GUARANTEES the row clears even when the
|
|
1282
1595
|
// turn throws; loop.ts only narrates activity changes in between.
|
|
@@ -1308,7 +1621,7 @@ export async function runRepl(opts) {
|
|
|
1308
1621
|
if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
|
|
1309
1622
|
setGuardedRunActive(true);
|
|
1310
1623
|
guardedRunFlagSet = true;
|
|
1311
|
-
chunkEmitter.emit('chunk',
|
|
1624
|
+
chunkEmitter.emit('chunk', formatInfo('Guarded mode · auto-approve is off for this run'));
|
|
1312
1625
|
}
|
|
1313
1626
|
// Off-switch (2026-07-06, owner) — announce plainly that this run's
|
|
1314
1627
|
// phases are NOT audited (honesty: off is a choice, never a lie).
|
|
@@ -1320,7 +1633,7 @@ export async function runRepl(opts) {
|
|
|
1320
1633
|
provider,
|
|
1321
1634
|
userMessage: prompt,
|
|
1322
1635
|
skill: skill,
|
|
1323
|
-
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook:
|
|
1636
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1324
1637
|
chunkEmitter,
|
|
1325
1638
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
1326
1639
|
signal: currentTurnAbort.signal,
|
|
@@ -1346,7 +1659,7 @@ export async function runRepl(opts) {
|
|
|
1346
1659
|
planSavedEarly = true;
|
|
1347
1660
|
}
|
|
1348
1661
|
catch (err) {
|
|
1349
|
-
chunkEmitter.emit('chunk',
|
|
1662
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
|
|
1350
1663
|
}
|
|
1351
1664
|
}
|
|
1352
1665
|
: planContinueForHook
|
|
@@ -1373,7 +1686,7 @@ export async function runRepl(opts) {
|
|
|
1373
1686
|
// owns the foreign save / quiet-none handling.
|
|
1374
1687
|
}
|
|
1375
1688
|
catch (err) {
|
|
1376
|
-
chunkEmitter.emit('chunk',
|
|
1689
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
|
|
1377
1690
|
}
|
|
1378
1691
|
}
|
|
1379
1692
|
: undefined,
|
|
@@ -1386,10 +1699,11 @@ export async function runRepl(opts) {
|
|
|
1386
1699
|
// to do. Same block classic mode prints; in-session it leads with
|
|
1387
1700
|
// "type continue" (the partial turn is already saved).
|
|
1388
1701
|
const { renderTurnError } = await import('./agent/turn-error-ux.js');
|
|
1702
|
+
turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
|
|
1389
1703
|
await renderTurnError({
|
|
1390
1704
|
err,
|
|
1391
1705
|
session,
|
|
1392
|
-
userInterrupted:
|
|
1706
|
+
userInterrupted: turnCancelledByUser,
|
|
1393
1707
|
inSession: true,
|
|
1394
1708
|
print: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1395
1709
|
});
|
|
@@ -1397,6 +1711,11 @@ export async function runRepl(opts) {
|
|
|
1397
1711
|
finally {
|
|
1398
1712
|
turnStatusEmitter.stop();
|
|
1399
1713
|
currentTurnAbort = null;
|
|
1714
|
+
// Re-review 2 — from here on a Ctrl+C can only stop an automatic
|
|
1715
|
+
// next plan phase (and only when this is a plan turn or a dispatch
|
|
1716
|
+
// is already queued); otherwise it behaves as idle.
|
|
1717
|
+
postTurn = true;
|
|
1718
|
+
postTurnMayChain = planResume !== null || planContinue !== null;
|
|
1400
1719
|
// Task 6/7/10 — neither the guarded auto_approve override, the
|
|
1401
1720
|
// plan-phase enforcement flag, nor the phase-writes declaration may
|
|
1402
1721
|
// outlive the phase turn. (The deviation flag DOES survive this
|
|
@@ -1439,7 +1758,7 @@ export async function runRepl(opts) {
|
|
|
1439
1758
|
});
|
|
1440
1759
|
}
|
|
1441
1760
|
catch (err) {
|
|
1442
|
-
chunkEmitter.emit('chunk',
|
|
1761
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
|
|
1443
1762
|
}
|
|
1444
1763
|
}
|
|
1445
1764
|
// A1 (defect D23) + Task 6 — harness-owned continuation, now the full
|
|
@@ -1452,7 +1771,7 @@ export async function runRepl(opts) {
|
|
|
1452
1771
|
if (planResume && planOutcome) {
|
|
1453
1772
|
try {
|
|
1454
1773
|
const { runPostPhaseChain } = await import('./commands/plan-chain.js');
|
|
1455
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1774
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
1456
1775
|
await runPostPhaseChain({
|
|
1457
1776
|
savedPath: planOutcome.savedPath,
|
|
1458
1777
|
executedPhaseId: planResume.nextPhaseId,
|
|
@@ -1470,12 +1789,13 @@ export async function runRepl(opts) {
|
|
|
1470
1789
|
// finally; thread the captured value for the report line.
|
|
1471
1790
|
subagentDispatches: planSubagentDispatches,
|
|
1472
1791
|
prompt: inkAwarePrompt,
|
|
1792
|
+
confirm: inkAwareConfirm,
|
|
1473
1793
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
1474
1794
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1475
1795
|
});
|
|
1476
1796
|
}
|
|
1477
1797
|
catch (err) {
|
|
1478
|
-
chunkEmitter.emit('chunk',
|
|
1798
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
|
|
1479
1799
|
}
|
|
1480
1800
|
}
|
|
1481
1801
|
// C1 — continuation post-turn: fallback-persist (hook may not have
|
|
@@ -1514,13 +1834,13 @@ export async function runRepl(opts) {
|
|
|
1514
1834
|
}
|
|
1515
1835
|
}
|
|
1516
1836
|
catch (err) {
|
|
1517
|
-
chunkEmitter.emit('chunk',
|
|
1837
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
|
|
1518
1838
|
}
|
|
1519
1839
|
}
|
|
1520
1840
|
if (planContinue && planOutcome) {
|
|
1521
1841
|
try {
|
|
1522
1842
|
const { runContinuationChain } = await import('./commands/plan-continue.js');
|
|
1523
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1843
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
1524
1844
|
await runContinuationChain({
|
|
1525
1845
|
prepared: planContinue.prepared,
|
|
1526
1846
|
marker: planContinue.marker,
|
|
@@ -1529,12 +1849,13 @@ export async function runRepl(opts) {
|
|
|
1529
1849
|
cwd: process.cwd(),
|
|
1530
1850
|
subagentDispatches: planSubagentDispatches,
|
|
1531
1851
|
prompt: inkAwarePrompt,
|
|
1852
|
+
confirm: inkAwareConfirm,
|
|
1532
1853
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
1533
1854
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1534
1855
|
});
|
|
1535
1856
|
}
|
|
1536
1857
|
catch (err) {
|
|
1537
|
-
chunkEmitter.emit('chunk',
|
|
1858
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
|
|
1538
1859
|
}
|
|
1539
1860
|
}
|
|
1540
1861
|
// C1 — the phase reached a resolved terminal (revision persisted, chain
|
|
@@ -1565,6 +1886,7 @@ export async function runRepl(opts) {
|
|
|
1565
1886
|
snapshot: turnSnapshot,
|
|
1566
1887
|
chunkEmitter,
|
|
1567
1888
|
turnModel: planModelOverride ?? session.model,
|
|
1889
|
+
cancelled: turnCancelledByUser,
|
|
1568
1890
|
});
|
|
1569
1891
|
session.last_skill = previousSkill ?? null;
|
|
1570
1892
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
@@ -1582,11 +1904,11 @@ export async function runRepl(opts) {
|
|
|
1582
1904
|
// the transcript reset — it runs for typed AND dispatched resumes,
|
|
1583
1905
|
// and only AFTER preparePlanResume succeeds, so a failed prepare
|
|
1584
1906
|
// never destroys the session context.
|
|
1585
|
-
chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
|
|
1586
1907
|
// Schedule on next tick so the current handleSubmit's promise
|
|
1587
1908
|
// resolves cleanly before the next one starts. Recursion via
|
|
1588
1909
|
// setImmediate keeps the call stack flat across long chains.
|
|
1589
|
-
|
|
1910
|
+
// (autoDispatch skips it when a post-turn Ctrl+C / /exit asked to stop.)
|
|
1911
|
+
autoDispatch(dispatched);
|
|
1590
1912
|
}
|
|
1591
1913
|
};
|
|
1592
1914
|
// BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
|
|
@@ -1605,47 +1927,143 @@ export async function runRepl(opts) {
|
|
|
1605
1927
|
// check here is a backstop for any direct caller that bypasses
|
|
1606
1928
|
// beginSubmit's guard — it queues the line as steering, exactly like
|
|
1607
1929
|
// beginSubmit does mid-turn, instead of starting a twin.
|
|
1930
|
+
/** Round 4 item 9b — the in-flight (or last) dispatch; settled before unmount on exit. */
|
|
1931
|
+
let dispatchTail = null;
|
|
1608
1932
|
const handleSubmit = async (line) => {
|
|
1609
1933
|
if (turnStatusEmitter.isBusy()) {
|
|
1610
1934
|
const t = line.trim();
|
|
1935
|
+
// Fix round 3 (item 5): slash commands are never steering text.
|
|
1936
|
+
const kind = classifyMidTurnLine(t);
|
|
1937
|
+
if (kind.kind === 'exit') {
|
|
1938
|
+
onExitRequest();
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
if (kind.kind === 'slash') {
|
|
1942
|
+
chunkEmitter.emit('chunk', midTurnSlashHint(kind.command));
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1611
1945
|
if (t.length > 0) {
|
|
1612
1946
|
enqueueSteering(t);
|
|
1613
|
-
|
|
1614
|
-
chunkEmitter.emit('chunk', chalk.cyan(`↳ queued ▸ ${echo}${t.length > echo.length ? '…' : ''}\n`));
|
|
1947
|
+
chunkEmitter.emit('chunk', steeringEchoLine(t));
|
|
1615
1948
|
}
|
|
1616
1949
|
return;
|
|
1617
1950
|
}
|
|
1618
1951
|
turnStatusEmitter.reserve();
|
|
1952
|
+
postTurn = false;
|
|
1953
|
+
postTurnMayChain = false;
|
|
1619
1954
|
try {
|
|
1620
|
-
|
|
1955
|
+
// Round 4 item 9b — the exit path waits (capped) for this to finish.
|
|
1956
|
+
const run = dispatchSubmit(line);
|
|
1957
|
+
dispatchTail = run;
|
|
1958
|
+
await run;
|
|
1621
1959
|
}
|
|
1622
1960
|
finally {
|
|
1623
1961
|
turnStatusEmitter.release();
|
|
1962
|
+
postTurn = false;
|
|
1963
|
+
postTurnMayChain = false;
|
|
1964
|
+
cancelAnnouncedPhase = null;
|
|
1965
|
+
// A cancel asked for during prep of a dispatch that never started a
|
|
1966
|
+
// turn must not leak into the next one.
|
|
1967
|
+
pendingCancel.clear();
|
|
1968
|
+
interruptCount = 0;
|
|
1969
|
+
// `/exit` typed mid-turn: the turn has now stopped and saved — exit.
|
|
1970
|
+
if (exitAfterTurn) {
|
|
1971
|
+
done = true;
|
|
1972
|
+
if (resolveDone)
|
|
1973
|
+
resolveDone();
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
};
|
|
1977
|
+
// Fix round 3 (item 5) — `/exit` / `/quit` typed while a turn runs:
|
|
1978
|
+
// cancel the turn the same way Ctrl+C does, then exit once dispatch
|
|
1979
|
+
// returns (handleSubmit's finally). Mid-prep (no abort controller yet)
|
|
1980
|
+
// the cancel is held and applied when the turn creates its controller,
|
|
1981
|
+
// or stops routing outright (review I2).
|
|
1982
|
+
let exitAfterTurn = false;
|
|
1983
|
+
// Final review B-I1 — a /rewind restore is not a turn and runs outside
|
|
1984
|
+
// handleSubmit: interrupts during it are decided as idle, /exit waits for
|
|
1985
|
+
// it, and the cancel/exit state is reset when it ends.
|
|
1986
|
+
const restoreGuard = createRestoreGuard({
|
|
1987
|
+
resetDispatchState: () => {
|
|
1988
|
+
cancelAnnouncedPhase = null;
|
|
1989
|
+
pendingCancel.clear();
|
|
1990
|
+
interruptCount = 0;
|
|
1991
|
+
},
|
|
1992
|
+
exit: () => { done = true; if (resolveDone)
|
|
1993
|
+
resolveDone(); },
|
|
1994
|
+
});
|
|
1995
|
+
const onExitRequest = () => {
|
|
1996
|
+
if (restoreGuard.active) {
|
|
1997
|
+
restoreGuard.requestExit();
|
|
1998
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting when the restore has finished'));
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
if (!turnStatusEmitter.isBusy() && !currentTurnAbort) {
|
|
2002
|
+
done = true;
|
|
2003
|
+
if (resolveDone)
|
|
2004
|
+
resolveDone();
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
exitAfterTurn = true;
|
|
2008
|
+
if (currentTurnAbort && interruptCount === 0) {
|
|
2009
|
+
interruptCount++;
|
|
2010
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · exiting when it has saved'));
|
|
2011
|
+
currentTurnAbort.abort();
|
|
2012
|
+
}
|
|
2013
|
+
else if (!currentTurnAbort) {
|
|
2014
|
+
announcePendingCancel(true);
|
|
1624
2015
|
}
|
|
1625
2016
|
};
|
|
1626
2017
|
// Phase D5 (2026-05-17) — Ctrl+C handler routed through Ink.
|
|
1627
2018
|
// First press during turn: abort the turn (preserves partial work).
|
|
1628
2019
|
// Second press during the SAME turn: hard-exit (escape hatch in case
|
|
1629
2020
|
// the abort doesn't take effect for some reason).
|
|
1630
|
-
// Press at idle:
|
|
2021
|
+
// Press at idle (fix round 3, item 4): the first press only arms the
|
|
2022
|
+
// exit — App shows "Press Ctrl+C again to exit" — and a second press
|
|
2023
|
+
// within IDLE_EXIT_WINDOW_MS exits cleanly (printGoodbye + leaveAltScreen).
|
|
2024
|
+
// Cards / sheets / palettes eat their own Ctrl+C and never get here.
|
|
2025
|
+
let idleInterruptAt = 0;
|
|
1631
2026
|
const onInterrupt = () => {
|
|
1632
|
-
|
|
2027
|
+
const now = Date.now();
|
|
2028
|
+
const action = decideInterrupt({
|
|
2029
|
+
turnActive: currentTurnAbort !== null,
|
|
2030
|
+
// Review I3 — routing / prep in flight without a controller yet is
|
|
2031
|
+
// not idle: cancel the pending turn, never arm "press again".
|
|
2032
|
+
busy: turnStatusEmitter.isBusy() && !restoreGuard.active,
|
|
2033
|
+
phase: cancelPhase(),
|
|
2034
|
+
canChain: canChainNow(),
|
|
2035
|
+
turnPresses: interruptCount,
|
|
2036
|
+
lastIdleAt: idleInterruptAt,
|
|
2037
|
+
now,
|
|
2038
|
+
});
|
|
2039
|
+
if (action === 'abort-turn') {
|
|
1633
2040
|
interruptCount++;
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
2041
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · saving the work done so far'));
|
|
2042
|
+
currentTurnAbort.abort();
|
|
2043
|
+
}
|
|
2044
|
+
else if (action === 'cancel-pending') {
|
|
2045
|
+
interruptCount++;
|
|
2046
|
+
announcePendingCancel(false);
|
|
2047
|
+
}
|
|
2048
|
+
else if (action === 'force-exit') {
|
|
2049
|
+
// Second Ctrl+C during the same turn — give up and exit.
|
|
2050
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting · second Ctrl+C'));
|
|
2051
|
+
unmount();
|
|
2052
|
+
process.stdout.write('\x1b[?25h'); // restore cursor
|
|
2053
|
+
printGoodbye(session);
|
|
2054
|
+
// Final review B M-2 — through gracefulExit (fetch drain, capped).
|
|
2055
|
+
// Re-review item 3 — mark the session done first, so nothing queued
|
|
2056
|
+
// (auto-dispatch, the loop) starts during the drain window.
|
|
2057
|
+
done = true;
|
|
2058
|
+
exitAfterTurn = true;
|
|
2059
|
+
void gracefulExit(130); // 128 + SIGINT
|
|
2060
|
+
}
|
|
2061
|
+
else if (action === 'arm') {
|
|
2062
|
+
idleInterruptAt = now;
|
|
2063
|
+
return 'armed';
|
|
1646
2064
|
}
|
|
1647
2065
|
else {
|
|
1648
|
-
//
|
|
2066
|
+
// Second idle press inside the window — clean exit, same path as /exit.
|
|
1649
2067
|
done = true;
|
|
1650
2068
|
if (resolveDone)
|
|
1651
2069
|
resolveDone();
|
|
@@ -1665,7 +2083,10 @@ export async function runRepl(opts) {
|
|
|
1665
2083
|
enableBracketedPaste();
|
|
1666
2084
|
process.on('exit', () => disableBracketedPaste());
|
|
1667
2085
|
const inkStdin = new InkStdinPasteGuard();
|
|
1668
|
-
|
|
2086
|
+
// Live fix 2026-09-26 — an @inquirer prompt that ran before this point
|
|
2087
|
+
// (resume picker, SAP picker, API key, first run, onboarding) leaves
|
|
2088
|
+
// Windows console stdin dead to the pipe. Cycle raw mode + flow first.
|
|
2089
|
+
pipeRevivedStdin(inkStdin, process.stdin);
|
|
1669
2090
|
// Task 3 (ux-wave3) — rewind wiring, shared with the classic path through
|
|
1670
2091
|
// executeRestore (which holds the single-flight reserve for the FULL
|
|
1671
2092
|
// restore — review I1a: a restore counts as busy, submits become steering —
|
|
@@ -1676,25 +2097,25 @@ export async function runRepl(opts) {
|
|
|
1676
2097
|
const inkRequestApproval = (change, risk, transport) => new Promise((resolve) => {
|
|
1677
2098
|
approvalEmitter.emit('request', { change, effectiveRisk: risk, transport, resolve });
|
|
1678
2099
|
});
|
|
1679
|
-
const runInkRestore = (c) => executeRestore(c, {
|
|
2100
|
+
const runInkRestore = (c) => restoreGuard.run(() => executeRestore(c, {
|
|
1680
2101
|
adt, cfg, alias: selectedAlias,
|
|
1681
|
-
toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook:
|
|
2102
|
+
toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1682
2103
|
suppliedTransport: currentTransportAccessor.get() ?? undefined,
|
|
1683
2104
|
requestApproval: inkRequestApproval,
|
|
1684
2105
|
// deps.log streams each pipeline step dim to scrollback live; the final
|
|
1685
|
-
//
|
|
2106
|
+
// the done / cancelled / failed summary comes from executeRestore's print.
|
|
1686
2107
|
log: (line) => chunkEmitter.emit('chunk', chalk.dim(line) + '\n'),
|
|
1687
|
-
}, (line) => chunkEmitter.emit('chunk', line + '\n'));
|
|
2108
|
+
}, (line) => chunkEmitter.emit('chunk', line + '\n')));
|
|
1688
2109
|
const { unmount } = render(React.createElement(App, {
|
|
1689
2110
|
chunkEmitter,
|
|
1690
2111
|
onSubmit: (line) => { void handleSubmit(line); },
|
|
1691
2112
|
onSlashTrigger: () => { },
|
|
1692
2113
|
onHistoryTrigger: () => { },
|
|
1693
2114
|
getToolCalls: () => session.toolCalls,
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
2115
|
+
// Live-checklist seed accessor (getToolCalls pattern; piece 2 P2):
|
|
2116
|
+
// App reads persisted todos on mount and when the rewind panel
|
|
2117
|
+
// closes, so a resumed session shows its plan before any todo_set
|
|
2118
|
+
// fires in this process.
|
|
1698
2119
|
getTodos: () => session.todos ?? [],
|
|
1699
2120
|
// Task 3 (ux-wave3) — rewind panel accessors. Candidates join
|
|
1700
2121
|
// session.toolCalls with the on-disk snapshot list (async fs read),
|
|
@@ -1711,6 +2132,7 @@ export async function runRepl(opts) {
|
|
|
1711
2132
|
sapRole: cfg.sap[selectedAlias]?.role,
|
|
1712
2133
|
sapAlias: selectedAlias,
|
|
1713
2134
|
onInterrupt,
|
|
2135
|
+
onExitRequest,
|
|
1714
2136
|
}), {
|
|
1715
2137
|
// Bug 2 (wave1 live-fix) — Ink reads from the paste guard, not
|
|
1716
2138
|
// process.stdin directly. The guard satisfies Ink's full stdin
|
|
@@ -1732,7 +2154,12 @@ export async function runRepl(opts) {
|
|
|
1732
2154
|
patchConsole: true,
|
|
1733
2155
|
});
|
|
1734
2156
|
await donePromise;
|
|
2157
|
+
// Round 4 item 9b — a second idle Ctrl+C right after a turn resolved the
|
|
2158
|
+
// exit while post-turn work (saving, audit) still ran. Let it finish,
|
|
2159
|
+
// capped so a stuck step can never hold the exit.
|
|
2160
|
+
const exitTail = await settleWithin(dispatchTail, EXIT_TAIL_CAP_MS);
|
|
1735
2161
|
unmount();
|
|
2162
|
+
setTraceEmitter(undefined);
|
|
1736
2163
|
// Bug 2 (wave1 live-fix) — undo the paste plumbing so the user's
|
|
1737
2164
|
// shell doesn't keep receiving marker-wrapped pastes after exit.
|
|
1738
2165
|
process.stdin.unpipe(inkStdin);
|
|
@@ -1744,6 +2171,11 @@ export async function runRepl(opts) {
|
|
|
1744
2171
|
// line lands in the user's shell scrollback alongside the session
|
|
1745
2172
|
// history. In inline mode the conversation IS in scrollback (no
|
|
1746
2173
|
// alt-screen erased it), so this is just a friendly bookend.
|
|
2174
|
+
// Review M7 — the cap expired: the process exits next, so say the save may
|
|
2175
|
+
// not have landed instead of cutting it off silently.
|
|
2176
|
+
if (exitTail === 'timeout') {
|
|
2177
|
+
console.log(formatAttention({ text: 'Post-turn work did not finish', fact: 'the session file may be incomplete' }).trimEnd());
|
|
2178
|
+
}
|
|
1747
2179
|
printGoodbye(session);
|
|
1748
2180
|
return 0;
|
|
1749
2181
|
}
|
|
@@ -1756,7 +2188,10 @@ export async function runRepl(opts) {
|
|
|
1756
2188
|
// a single line before readline sees it. See src/repl/bracketed-paste.ts.
|
|
1757
2189
|
enableBracketedPaste();
|
|
1758
2190
|
const pasteInput = new BracketedPasteDecoder();
|
|
1759
|
-
|
|
2191
|
+
// Review I2: the classic path is reached through the same pre-REPL inquirer
|
|
2192
|
+
// prompts (resume picker, SAP picker, password, first run) — revive stdin
|
|
2193
|
+
// with the full reset cycle before piping, exactly like the Ink branch.
|
|
2194
|
+
pipeRevivedStdin(pasteInput, process.stdin);
|
|
1760
2195
|
const history = loadHistory();
|
|
1761
2196
|
const rl = readline.createInterface({
|
|
1762
2197
|
input: pasteInput,
|
|
@@ -1790,7 +2225,7 @@ export async function runRepl(opts) {
|
|
|
1790
2225
|
// empty. Attached ONLY for non-TTY stdin: interactive type-ahead during
|
|
1791
2226
|
// a turn keeps its existing semantics.
|
|
1792
2227
|
const pipedStdin = !process.stdin.isTTY;
|
|
1793
|
-
const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl) : noopEarlyLineBuffer();
|
|
2228
|
+
const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl, process.stdin) : noopEarlyLineBuffer();
|
|
1794
2229
|
// B2 fix (2026-06-12) — classic-mode notice channel. Tool handlers emit
|
|
1795
2230
|
// 'warn'/'info' (and pre-formatted 'chunk' lines like the write verify
|
|
1796
2231
|
// chain) on ctx.chunkEmitter; in classic mode that slot used to be
|
|
@@ -1800,8 +2235,9 @@ export async function runRepl(opts) {
|
|
|
1800
2235
|
// keep their direct-stdout classic path (spinner stays enabled).
|
|
1801
2236
|
const classicOutputEmitter = createClassicOutputEmitter();
|
|
1802
2237
|
// Prompt string: 'CSPeach ' + '›' in peach colour
|
|
1803
|
-
|
|
1804
|
-
|
|
2238
|
+
// I1 — the classic prompt is not legacy-pinned; it takes the brand role (spec §1.1).
|
|
2239
|
+
const promptStr = 'CSPeach ' + paint('brand', '›') + ' ';
|
|
2240
|
+
// When we dispatch an inquirer sub-prompt (e.g. the coaching picker),
|
|
1805
2241
|
// readline is paused to give inquirer exclusive stdin access. On resolve
|
|
1806
2242
|
// inquirer sometimes disturbs the stdin stream in a way that makes our
|
|
1807
2243
|
// readline fire 'close' even though the user did NOT press Ctrl-D. Suppress
|
|
@@ -1835,7 +2271,7 @@ export async function runRepl(opts) {
|
|
|
1835
2271
|
// v0.6 — module-scope flag the per-turn SIGINT handler sets to true while
|
|
1836
2272
|
// a turn is in flight. Both `rl.on('close')` and `rl.on('SIGINT')` below
|
|
1837
2273
|
// check this flag and bail out so they don't race against the turn-scoped
|
|
1838
|
-
// handler that wants to abort the LLM stream and render "
|
|
2274
|
+
// handler that wants to abort the LLM stream and render "· Turn cancelled"
|
|
1839
2275
|
// Without this flag, Ctrl+C during a stream lands in the readline SIGINT
|
|
1840
2276
|
// path first (line buffer is empty during streaming → "Goodbye" + exit),
|
|
1841
2277
|
// killing the CLI before the turn handler can do anything useful.
|
|
@@ -1875,7 +2311,7 @@ export async function runRepl(opts) {
|
|
|
1875
2311
|
// v0.6 — turn-scoped SIGINT handler (registered just before runTurn
|
|
1876
2312
|
// and torn down right after) takes precedence. If a turn is in flight,
|
|
1877
2313
|
// the user wants to interrupt THE TURN, not exit the CLI. Returning
|
|
1878
|
-
// early lets the turn handler run its abort + "
|
|
2314
|
+
// early lets the turn handler run its abort + "· Turn cancelled" UX.
|
|
1879
2315
|
if (turnInProgress)
|
|
1880
2316
|
return;
|
|
1881
2317
|
// Inspect the readline's internal line buffer + our prefill state.
|
|
@@ -2001,9 +2437,11 @@ export async function runRepl(opts) {
|
|
|
2001
2437
|
// can pause us and suppress the spurious 'close' inquirer emits on
|
|
2002
2438
|
// release. See src/repl/inquirer-guard.ts for the failure mode this
|
|
2003
2439
|
// prevents (CSPeach silently exiting after a tool-mediated prompt).
|
|
2440
|
+
// Fix 8 (2026-09-26): pass pasteInput so a prompt detaches it from stdin —
|
|
2441
|
+
// keys typed into the prompt must not be replayed into the REPL line.
|
|
2004
2442
|
registerReadline(rl, (v) => {
|
|
2005
2443
|
suppressRlClose = v;
|
|
2006
|
-
});
|
|
2444
|
+
}, { input: pasteInput });
|
|
2007
2445
|
// 2026-05-08: one-time workspace-default migration. The default workspace
|
|
2008
2446
|
// changed from `~/CSPeach/` to project-rooted `<git-root>/.cspeach/`.
|
|
2009
2447
|
// Existing users with files in ~/CSPeach/ get asked once how to handle
|
|
@@ -2058,7 +2496,7 @@ export async function runRepl(opts) {
|
|
|
2058
2496
|
// transcript reset — it runs for typed AND dispatched resumes, and
|
|
2059
2497
|
// only AFTER preparePlanResume succeeds, so a failed prepare never
|
|
2060
2498
|
// destroys the session context.
|
|
2061
|
-
console.log(
|
|
2499
|
+
console.log(formatInfo(`Auto-routed: ${dispatchedCmd}`).trimEnd());
|
|
2062
2500
|
line = dispatchedCmd;
|
|
2063
2501
|
}
|
|
2064
2502
|
else if (earlyLines.size() > 0) {
|
|
@@ -2114,6 +2552,9 @@ export async function runRepl(opts) {
|
|
|
2114
2552
|
const trimmed = line.trim();
|
|
2115
2553
|
if (!trimmed)
|
|
2116
2554
|
continue;
|
|
2555
|
+
// Round 6 review I2 — see the Ink path.
|
|
2556
|
+
const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
|
|
2557
|
+
resumeKeepsSkill = false;
|
|
2117
2558
|
// Persist non-empty input to history file (Task 8: consecutive
|
|
2118
2559
|
// duplicates are skipped inside the shared writer).
|
|
2119
2560
|
appendHistoryEntry(trimmed);
|
|
@@ -2147,7 +2588,7 @@ export async function runRepl(opts) {
|
|
|
2147
2588
|
continue;
|
|
2148
2589
|
}
|
|
2149
2590
|
if (trimmed.endsWith(' /cancel')) {
|
|
2150
|
-
console.log(
|
|
2591
|
+
console.log(formatRoutingLine({ kind: 'cancelled' }).trimEnd());
|
|
2151
2592
|
continue;
|
|
2152
2593
|
}
|
|
2153
2594
|
if (trimmed === '/exit' || trimmed === '/quit') {
|
|
@@ -2215,7 +2656,7 @@ export async function runRepl(opts) {
|
|
|
2215
2656
|
if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
|
|
2216
2657
|
const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
|
|
2217
2658
|
const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
|
|
2218
|
-
console.log(
|
|
2659
|
+
console.log(formatInfo(modeLine).trimEnd());
|
|
2219
2660
|
sapStateStore.update({ writeMode: mode });
|
|
2220
2661
|
continue;
|
|
2221
2662
|
}
|
|
@@ -2228,13 +2669,9 @@ export async function runRepl(opts) {
|
|
|
2228
2669
|
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
2229
2670
|
const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
|
|
2230
2671
|
console.log('');
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
else {
|
|
2235
|
-
for (const recapLine of recapLines)
|
|
2236
|
-
console.log(chalk.dim(recapLine));
|
|
2237
|
-
}
|
|
2672
|
+
console.log(recapLines.length === 0
|
|
2673
|
+
? formatInfo('Nothing to recap yet').trimEnd()
|
|
2674
|
+
: formatPanel('Recap', recapLines.map((l) => ' ' + paint('dim', sanitizeControlChars(l)))).join('\n'));
|
|
2238
2675
|
continue;
|
|
2239
2676
|
}
|
|
2240
2677
|
// Phase 2 conversation hygiene (2026-05-16) — classic-mode /compact.
|
|
@@ -2252,12 +2689,12 @@ export async function runRepl(opts) {
|
|
|
2252
2689
|
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
2253
2690
|
};
|
|
2254
2691
|
try {
|
|
2255
|
-
console.log(
|
|
2692
|
+
console.log('\n' + formatInfo('Compacting older turns into a summary…').trimEnd());
|
|
2256
2693
|
const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
|
|
2257
2694
|
console.log(formatCompactResult(result, compactCfg.keep_recent_turns));
|
|
2258
2695
|
}
|
|
2259
2696
|
catch (err) {
|
|
2260
|
-
console.log(
|
|
2697
|
+
console.log('\n' + formatFailure({ what: 'Compact failed', detail: [err instanceof Error ? err.message : String(err)] }));
|
|
2261
2698
|
}
|
|
2262
2699
|
continue;
|
|
2263
2700
|
}
|
|
@@ -2278,7 +2715,7 @@ export async function runRepl(opts) {
|
|
|
2278
2715
|
const { openFilePicker } = await import('./repl/file-picker.js');
|
|
2279
2716
|
const result = await openFilePicker();
|
|
2280
2717
|
if (result.kind === 'command') {
|
|
2281
|
-
console.log(
|
|
2718
|
+
console.log(formatInfo('Picked · press Enter to run, or edit first').trimEnd());
|
|
2282
2719
|
pendingPrefill = result.command;
|
|
2283
2720
|
}
|
|
2284
2721
|
else if (result.kind === 'empty') {
|
|
@@ -2293,7 +2730,7 @@ export async function runRepl(opts) {
|
|
|
2293
2730
|
// result.kind === 'cancelled' — print nothing.
|
|
2294
2731
|
}
|
|
2295
2732
|
catch (err) {
|
|
2296
|
-
console.log(
|
|
2733
|
+
console.log('\n' + formatFailure({ what: 'Could not open the file list', detail: [err.message] }));
|
|
2297
2734
|
}
|
|
2298
2735
|
continue;
|
|
2299
2736
|
}
|
|
@@ -2302,31 +2739,11 @@ export async function runRepl(opts) {
|
|
|
2302
2739
|
// Defensive: a config that predates the [ui] section may have cfg.ui === undefined
|
|
2303
2740
|
// if loaded before loader.ts's deep-merge fix. Initialise to the default.
|
|
2304
2741
|
if (!cfg.ui)
|
|
2305
|
-
cfg.ui = { rendering: 'auto' };
|
|
2742
|
+
cfg.ui = { rendering: 'auto', look: DEFAULT_CONFIG.ui.look };
|
|
2306
2743
|
const arg = trimmed.slice(3).trim().toLowerCase();
|
|
2307
|
-
if (arg
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
console.log(chalk.dim(' /ui classic — chalk REPL (v0.2 style — default, fully tested)'));
|
|
2311
|
-
console.log(chalk.dim(' /ui ink — Ink frame (v0.3 — opt-in while polish is pending)'));
|
|
2312
|
-
console.log(chalk.dim(' /ui auto — choose automatically based on terminal'));
|
|
2313
|
-
console.log(chalk.dim('\nChanges are written to ~/.cspeach/config.toml and'));
|
|
2314
|
-
console.log(chalk.dim('take effect the next time you launch cspeach.\n'));
|
|
2315
|
-
}
|
|
2316
|
-
else if (arg === 'auto' || arg === 'ink' || arg === 'classic') {
|
|
2317
|
-
cfg.ui.rendering = arg;
|
|
2318
|
-
try {
|
|
2319
|
-
await saveConfig(cfg);
|
|
2320
|
-
console.log(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
|
|
2321
|
-
console.log(chalk.dim('Relaunch cspeach (`/exit` then `cspeach`) to apply.\n'));
|
|
2322
|
-
}
|
|
2323
|
-
catch (err) {
|
|
2324
|
-
console.log(chalk.red(`\n✗ Failed to save config: ${err.message}\n`));
|
|
2325
|
-
}
|
|
2326
|
-
}
|
|
2327
|
-
else {
|
|
2328
|
-
console.log(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.\n`));
|
|
2329
|
-
}
|
|
2744
|
+
if (await handleUiLook(arg, cfg, saveConfig, (s) => console.log(s)))
|
|
2745
|
+
continue;
|
|
2746
|
+
await handleUiRendering(arg, cfg, saveConfig, (s) => console.log(s), shouldUseInk() ? 'ink' : 'classic');
|
|
2330
2747
|
continue;
|
|
2331
2748
|
}
|
|
2332
2749
|
// /reroute — re-dispatch session.lastUserPrompt to a different skill
|
|
@@ -2338,15 +2755,25 @@ export async function runRepl(opts) {
|
|
|
2338
2755
|
continue;
|
|
2339
2756
|
}
|
|
2340
2757
|
const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
|
|
2341
|
-
console.log(
|
|
2758
|
+
console.log(formatInfo(`Rerouting "${preview}" to /${rr.skill}`).trimEnd());
|
|
2342
2759
|
if (rr.warning)
|
|
2343
2760
|
console.log(chalk.dim(` (${rr.warning})`));
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2761
|
+
// A turn error prints the same stderr block as the normal turn path.
|
|
2762
|
+
await runRerouteTurn({
|
|
2763
|
+
run: () => runTurn({
|
|
2764
|
+
provider,
|
|
2765
|
+
userMessage: rr.prompt,
|
|
2766
|
+
skill: rr.skill,
|
|
2767
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
2768
|
+
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
2769
|
+
}),
|
|
2770
|
+
session,
|
|
2771
|
+
print: (line) => console.error(line),
|
|
2772
|
+
onFinally: () => {
|
|
2773
|
+
// P2 fix round 1 — /reroute skips pushPostTurnStatus, so run its
|
|
2774
|
+
// turn-end plan step here (classic prints the block once).
|
|
2775
|
+
emitPlanTurnEnd(session);
|
|
2776
|
+
},
|
|
2350
2777
|
});
|
|
2351
2778
|
continue;
|
|
2352
2779
|
}
|
|
@@ -2354,7 +2781,8 @@ export async function runRepl(opts) {
|
|
|
2354
2781
|
// classified afresh instead of continuing the previous skill's Q&A chain.
|
|
2355
2782
|
if (trimmed === '/new' || trimmed === '/reset') {
|
|
2356
2783
|
session.awaitingSkillAnswer = false;
|
|
2357
|
-
|
|
2784
|
+
session.todos = []; // P2 — /new clears the plan (spec §4)
|
|
2785
|
+
console.log(formatInfo('Fresh start · the next prompt picks a skill again').trimEnd());
|
|
2358
2786
|
continue;
|
|
2359
2787
|
}
|
|
2360
2788
|
// /transport — session current-transport state for Rule 9 (transport isolation)
|
|
@@ -2409,7 +2837,7 @@ export async function runRepl(opts) {
|
|
|
2409
2837
|
// the prefilled buffer so iter 3 starts fresh, clear the
|
|
2410
2838
|
// guard, and skip dispatch.
|
|
2411
2839
|
lastCancelledSlashLine = null;
|
|
2412
|
-
console.log(
|
|
2840
|
+
console.log(formatInfo('Dropped').trimEnd());
|
|
2413
2841
|
process.nextTick(() => prefillReadline(rl, ''));
|
|
2414
2842
|
continue;
|
|
2415
2843
|
}
|
|
@@ -2421,7 +2849,7 @@ export async function runRepl(opts) {
|
|
|
2421
2849
|
const prefix = head.slice(1); // drop the leading /
|
|
2422
2850
|
const picked = await openSlashPicker(prefix);
|
|
2423
2851
|
if (picked === null) {
|
|
2424
|
-
console.log(
|
|
2852
|
+
console.log(formatInfo('Cancelled · edit, or press Enter again to drop').trimEnd());
|
|
2425
2853
|
lastCancelledSlashLine = trimmed;
|
|
2426
2854
|
process.nextTick(() => prefillReadline(rl, trimmed));
|
|
2427
2855
|
continue;
|
|
@@ -2448,13 +2876,13 @@ export async function runRepl(opts) {
|
|
|
2448
2876
|
// Without this the line went to the LLM with a bare/partial
|
|
2449
2877
|
// @ — wasted a turn and confused the user who'd just cancelled.
|
|
2450
2878
|
lastCancelledAtLine = null;
|
|
2451
|
-
console.log(
|
|
2879
|
+
console.log(formatInfo('Dropped').trimEnd());
|
|
2452
2880
|
process.nextTick(() => prefillReadline(rl, ''));
|
|
2453
2881
|
continue;
|
|
2454
2882
|
}
|
|
2455
2883
|
const pickedFile = await openAtPicker(atPrefix);
|
|
2456
2884
|
if (pickedFile === null) {
|
|
2457
|
-
console.log(
|
|
2885
|
+
console.log(formatInfo('Cancelled · edit, or press Enter again to drop').trimEnd());
|
|
2458
2886
|
lastCancelledAtLine = resolvedTrimmed;
|
|
2459
2887
|
process.nextTick(() => prefillReadline(rl, resolvedTrimmed));
|
|
2460
2888
|
continue;
|
|
@@ -2499,9 +2927,7 @@ export async function runRepl(opts) {
|
|
|
2499
2927
|
// then asked an unanswerable question. Every skill now carries an
|
|
2500
2928
|
// `asks` line with an example (skill-catalog.ts).
|
|
2501
2929
|
const asks = SKILL_CATALOG.find((e) => `/${e.name}` === head)?.asks;
|
|
2502
|
-
console.log(
|
|
2503
|
-
? `↪ ${head} — ${asks}`
|
|
2504
|
-
: `${'↪'} ${head} — type your prompt and press Enter`));
|
|
2930
|
+
console.log(formatInfo(asks ? `${head} — ${asks}` : `${head} — type your prompt and press Enter`).trimEnd());
|
|
2505
2931
|
console.log(chalk.dim(' Enter to send · /cancel to abandon'));
|
|
2506
2932
|
lastEmptyPrefill = head;
|
|
2507
2933
|
}
|
|
@@ -2567,15 +2993,16 @@ export async function runRepl(opts) {
|
|
|
2567
2993
|
chalk.dim('Type a new question, /new to reset, or /exit to quit.'));
|
|
2568
2994
|
continue;
|
|
2569
2995
|
}
|
|
2570
|
-
|
|
2571
|
-
|
|
2996
|
+
// Round 6 item 4c / review I2 — see the Ink path.
|
|
2997
|
+
if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
|
|
2998
|
+
console.log(formatRoutingLine({ kind: 'continuing', skill: session.skill }).trimEnd());
|
|
2572
2999
|
skill = session.skill;
|
|
2573
3000
|
}
|
|
2574
3001
|
else if (isBareAck(trimmed)) {
|
|
2575
3002
|
// Bare "y" / "n" / "cancel" with no skill to continue — almost always
|
|
2576
3003
|
// a stray keystroke after an aborted turn, not a request to start
|
|
2577
3004
|
// work. Don't burn a classifier round-trip on it.
|
|
2578
|
-
console.log(
|
|
3005
|
+
console.log(formatInfo('No active skill · type /help or a full prompt').trimEnd());
|
|
2579
3006
|
continue;
|
|
2580
3007
|
}
|
|
2581
3008
|
else {
|
|
@@ -2585,46 +3012,76 @@ export async function runRepl(opts) {
|
|
|
2585
3012
|
last_skill: session.last_skill ?? null,
|
|
2586
3013
|
last_object: extractedObject ?? session.last_object ?? null,
|
|
2587
3014
|
});
|
|
2588
|
-
if (c.offline) {
|
|
2589
|
-
console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
|
|
2590
|
-
}
|
|
2591
3015
|
if (c.screenshot)
|
|
2592
|
-
console.log(
|
|
2593
|
-
|
|
2594
|
-
//
|
|
2595
|
-
//
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
// withInquirer guard so the suppress-close window stays active
|
|
2601
|
-
// across the close-fire tick (inline guard cleared the flag
|
|
2602
|
-
// synchronously, leaking the close-fire and dropping CSPeach to
|
|
2603
|
-
// the shell mid-session).
|
|
2604
|
-
const runPicker = async (params) => {
|
|
2605
|
-
return withInquirer(() => coachingPickerClassic(params));
|
|
2606
|
-
};
|
|
2607
|
-
if (cfg.classifier.safe_mode) {
|
|
2608
|
-
// Safe mode overrides the decision — always open the full picker.
|
|
2609
|
-
pickedResult = await runPicker(buildCoachingParams(c, trimmed, false, continueSkillClassic));
|
|
2610
|
-
}
|
|
2611
|
-
else if (decision.kind === 'silent') {
|
|
2612
|
-
console.log(chalk.dim(`┄ Routed to /${decision.skill}`));
|
|
2613
|
-
pickedResult = decision.skill;
|
|
3016
|
+
console.log(formatInfo(`Screenshot: ${c.screenshot}`).trimEnd());
|
|
3017
|
+
// Fix 10 (2026-09-26): routing failed mid-skill → stay in the
|
|
3018
|
+
// active skill (one dim line), never the picker. Same decision as
|
|
3019
|
+
// the Ink path (routingFailureOutcome).
|
|
3020
|
+
const failed = routingFailureOutcome(routingFailureFromResult(c), session.skill);
|
|
3021
|
+
if (failed.kind === 'stay') {
|
|
3022
|
+
console.log(formatInfo(failed.line).trimEnd());
|
|
3023
|
+
skill = failed.skill;
|
|
2614
3024
|
}
|
|
2615
3025
|
else {
|
|
2616
|
-
|
|
2617
|
-
|
|
3026
|
+
if (c.offline) {
|
|
3027
|
+
// Fix 9: true wording from the failure kind (a timeout is not "offline").
|
|
3028
|
+
console.log(formatAttention(routingFailureNotice(routingFailureFromResult(c), c.transport_error)).trimEnd());
|
|
3029
|
+
}
|
|
3030
|
+
// Round 6 review I2 — see the Ink path.
|
|
3031
|
+
const decision = resumedSkill ? applyResumedSkill(decideRouting(c), resumedSkill) : decideRouting(c);
|
|
3032
|
+
// 2026-05-17 — pass the just-used skill so the picker can
|
|
3033
|
+
// default cursor to it. session.skill = immediately-previous
|
|
3034
|
+
// turn's skill, NOT session.last_skill (TWO turns ago).
|
|
3035
|
+
const continueSkillClassic = session.skill ?? null;
|
|
3036
|
+
let pickedResult;
|
|
3037
|
+
// Coaching picker path — inquirer takes over stdin. Use the shared
|
|
3038
|
+
// withInquirer guard so the suppress-close window stays active
|
|
3039
|
+
// across the close-fire tick (inline guard cleared the flag
|
|
3040
|
+
// synchronously, leaking the close-fire and dropping CSPeach to
|
|
3041
|
+
// the shell mid-session).
|
|
3042
|
+
const runPicker = async (params) => {
|
|
3043
|
+
return withInquirer(() => coachingPickerClassic(params));
|
|
3044
|
+
};
|
|
3045
|
+
if (pipedStdin) {
|
|
3046
|
+
// Round 7 item 8a — piped stdin has no keyboard to answer the
|
|
3047
|
+
// picker (it ended in "readline was closed"): run the router's
|
|
3048
|
+
// top pick, like one-shot.
|
|
3049
|
+
const p = pipedRoutingPick(c);
|
|
3050
|
+
for (const l of p.lines)
|
|
3051
|
+
console.log(l);
|
|
3052
|
+
pickedResult = p.skill;
|
|
3053
|
+
}
|
|
3054
|
+
else if (cfg.classifier.safe_mode) {
|
|
3055
|
+
// Safe mode overrides the decision — always open the full picker.
|
|
3056
|
+
pickedResult = await runPicker(buildCoachingParams(c, trimmed, false, continueSkillClassic));
|
|
3057
|
+
}
|
|
3058
|
+
else if (decision.kind === 'silent') {
|
|
3059
|
+
console.log(formatRoutingLine({ kind: 'using', skill: decision.skill }).trimEnd());
|
|
3060
|
+
pickedResult = decision.skill;
|
|
3061
|
+
}
|
|
3062
|
+
else {
|
|
3063
|
+
const preSelect = decision.kind === 'coach';
|
|
3064
|
+
pickedResult = await runPicker(buildCoachingParams(decision.result, trimmed, preSelect, continueSkillClassic));
|
|
3065
|
+
}
|
|
3066
|
+
if (pickedResult === PICK_CANCELLED) {
|
|
3067
|
+
console.log(formatRoutingLine({ kind: 'cancelled' }).trimEnd());
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
skill = pickedResult;
|
|
3071
|
+
} // end fix-10 fallback branch
|
|
3072
|
+
}
|
|
3073
|
+
catch (err) {
|
|
3074
|
+
const failure = routingFailureFromError(err);
|
|
3075
|
+
const failed = routingFailureOutcome(failure, session.skill);
|
|
3076
|
+
if (failed.kind === 'stay') {
|
|
3077
|
+
// Fix 10: HTTP routing failure mid-skill → stay in the active skill.
|
|
3078
|
+
console.log(formatInfo(failed.line).trimEnd());
|
|
3079
|
+
skill = failed.skill;
|
|
2618
3080
|
}
|
|
2619
|
-
|
|
2620
|
-
console.log(
|
|
3081
|
+
else {
|
|
3082
|
+
console.log('\n' + formatFailure({ what: failure ? routingFailureText(failure) : 'Could not pick a skill for this prompt', detail: [err.message], next: 'start the prompt with a skill, e.g. /abap-explain' }).trimEnd());
|
|
2621
3083
|
continue;
|
|
2622
3084
|
}
|
|
2623
|
-
skill = pickedResult;
|
|
2624
|
-
}
|
|
2625
|
-
catch (err) {
|
|
2626
|
-
console.log(chalk.red(`\n[classifier error] ${err.message}`));
|
|
2627
|
-
continue;
|
|
2628
3085
|
}
|
|
2629
3086
|
}
|
|
2630
3087
|
}
|
|
@@ -2652,7 +3109,7 @@ export async function runRepl(opts) {
|
|
|
2652
3109
|
});
|
|
2653
3110
|
}
|
|
2654
3111
|
catch (err) {
|
|
2655
|
-
console.log(
|
|
3112
|
+
console.log('\n' + formatFailure({ what: 'Could not show the status', detail: [err.message] }));
|
|
2656
3113
|
}
|
|
2657
3114
|
session.last_skill = previousSkill ?? null;
|
|
2658
3115
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
@@ -2677,7 +3134,7 @@ export async function runRepl(opts) {
|
|
|
2677
3134
|
if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
|
|
2678
3135
|
try {
|
|
2679
3136
|
const { preparePlanResume } = await import('./commands/plan-resume.js');
|
|
2680
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
3137
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
2681
3138
|
planResume = await preparePlanResume({
|
|
2682
3139
|
body: prompt,
|
|
2683
3140
|
cwd: process.cwd(),
|
|
@@ -2688,11 +3145,12 @@ export async function runRepl(opts) {
|
|
|
2688
3145
|
// Task 9 — run-lease identity + takeover prompt (classic mirror).
|
|
2689
3146
|
sessionId: session.id,
|
|
2690
3147
|
prompt: inkAwarePrompt,
|
|
3148
|
+
confirm: inkAwareConfirm,
|
|
2691
3149
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2692
3150
|
});
|
|
2693
3151
|
}
|
|
2694
3152
|
catch (err) {
|
|
2695
|
-
console.log(
|
|
3153
|
+
console.log('\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
|
|
2696
3154
|
}
|
|
2697
3155
|
if (!planResume) {
|
|
2698
3156
|
session.last_skill = previousSkill ?? null;
|
|
@@ -2706,7 +3164,7 @@ export async function runRepl(opts) {
|
|
|
2706
3164
|
if (planResume.reauditPhaseId) {
|
|
2707
3165
|
try {
|
|
2708
3166
|
const { runReauditPass } = await import('./commands/plan-chain.js');
|
|
2709
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
3167
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
2710
3168
|
await runReauditPass({
|
|
2711
3169
|
savedPath: planResume.path,
|
|
2712
3170
|
phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
|
|
@@ -2718,12 +3176,13 @@ export async function runRepl(opts) {
|
|
|
2718
3176
|
auditMode: planResume.auditMode,
|
|
2719
3177
|
auditFlag: planResume.auditFlag,
|
|
2720
3178
|
prompt: inkAwarePrompt,
|
|
3179
|
+
confirm: inkAwareConfirm,
|
|
2721
3180
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
2722
3181
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2723
3182
|
});
|
|
2724
3183
|
}
|
|
2725
3184
|
catch (err) {
|
|
2726
|
-
console.log(
|
|
3185
|
+
console.log('\n' + formatFailure({ what: 'Plan re-audit failed', detail: [err.message] }));
|
|
2727
3186
|
}
|
|
2728
3187
|
// Task 9 (review fix) — release ONLY when the re-audit leg queued no
|
|
2729
3188
|
// follow-up (classic mirror; the loop-top pre-prompt consumer picks
|
|
@@ -2742,7 +3201,7 @@ export async function runRepl(opts) {
|
|
|
2742
3201
|
{
|
|
2743
3202
|
const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
|
|
2744
3203
|
if (resetContextForPlanResume(session)) {
|
|
2745
|
-
console.log(
|
|
3204
|
+
console.log(formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared').trimEnd());
|
|
2746
3205
|
}
|
|
2747
3206
|
}
|
|
2748
3207
|
planResumeMsgStart = session.messages.length;
|
|
@@ -2788,7 +3247,7 @@ export async function runRepl(opts) {
|
|
|
2788
3247
|
planContinue = { prepared: r.prepared, marker };
|
|
2789
3248
|
planPhaseStartedAt = marker.attemptStartedAt;
|
|
2790
3249
|
planResumeMsgStart = session.messages.length;
|
|
2791
|
-
console.log(
|
|
3250
|
+
console.log(formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`).trimEnd());
|
|
2792
3251
|
}
|
|
2793
3252
|
else if (r.stale) {
|
|
2794
3253
|
session.plan_in_flight = null; // plan/phase gone for good — marker is noise
|
|
@@ -2843,7 +3302,7 @@ export async function runRepl(opts) {
|
|
|
2843
3302
|
return;
|
|
2844
3303
|
turnSigintFired = true;
|
|
2845
3304
|
interruptedByUser = true;
|
|
2846
|
-
process.stderr.write(
|
|
3305
|
+
process.stderr.write('\n' + formatInfo('Stopping the turn · saving the work done so far'));
|
|
2847
3306
|
turnAbort.abort();
|
|
2848
3307
|
// Remove ourselves so a SECOND Ctrl+C falls through to the
|
|
2849
3308
|
// existing rl SIGINT handler (which clears the line / exits) once
|
|
@@ -2871,7 +3330,7 @@ export async function runRepl(opts) {
|
|
|
2871
3330
|
if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
|
|
2872
3331
|
setGuardedRunActive(true);
|
|
2873
3332
|
guardedRunFlagSet = true;
|
|
2874
|
-
console.log(
|
|
3333
|
+
console.log(formatInfo('Guarded mode · auto-approve is off for this run').trimEnd());
|
|
2875
3334
|
}
|
|
2876
3335
|
// Off-switch (2026-07-06, owner) — announce plainly that this run's
|
|
2877
3336
|
// phases are NOT audited (honesty: off is a choice, never a lie).
|
|
@@ -2906,7 +3365,7 @@ export async function runRepl(opts) {
|
|
|
2906
3365
|
planSavedEarly = true;
|
|
2907
3366
|
}
|
|
2908
3367
|
catch (err) {
|
|
2909
|
-
console.log(
|
|
3368
|
+
console.log('\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
|
|
2910
3369
|
}
|
|
2911
3370
|
}
|
|
2912
3371
|
: planContinueForHook
|
|
@@ -2933,7 +3392,7 @@ export async function runRepl(opts) {
|
|
|
2933
3392
|
// owns the foreign save / quiet-none handling.
|
|
2934
3393
|
}
|
|
2935
3394
|
catch (err) {
|
|
2936
|
-
console.log(
|
|
3395
|
+
console.log('\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
|
|
2937
3396
|
}
|
|
2938
3397
|
}
|
|
2939
3398
|
: undefined,
|
|
@@ -2990,7 +3449,7 @@ export async function runRepl(opts) {
|
|
|
2990
3449
|
});
|
|
2991
3450
|
}
|
|
2992
3451
|
catch (err) {
|
|
2993
|
-
console.log(
|
|
3452
|
+
console.log('\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
|
|
2994
3453
|
}
|
|
2995
3454
|
}
|
|
2996
3455
|
// A1 (defect D23) + Task 6 — harness-owned continuation, now the full
|
|
@@ -3001,7 +3460,7 @@ export async function runRepl(opts) {
|
|
|
3001
3460
|
if (planResume && planOutcome) {
|
|
3002
3461
|
try {
|
|
3003
3462
|
const { runPostPhaseChain } = await import('./commands/plan-chain.js');
|
|
3004
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
3463
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
3005
3464
|
await runPostPhaseChain({
|
|
3006
3465
|
savedPath: planOutcome.savedPath,
|
|
3007
3466
|
executedPhaseId: planResume.nextPhaseId,
|
|
@@ -3018,12 +3477,13 @@ export async function runRepl(opts) {
|
|
|
3018
3477
|
// Fix-wave (item 1) — counter consumed in the runTurn finally.
|
|
3019
3478
|
subagentDispatches: planSubagentDispatches,
|
|
3020
3479
|
prompt: inkAwarePrompt,
|
|
3480
|
+
confirm: inkAwareConfirm,
|
|
3021
3481
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
3022
3482
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
3023
3483
|
});
|
|
3024
3484
|
}
|
|
3025
3485
|
catch (err) {
|
|
3026
|
-
console.log(
|
|
3486
|
+
console.log('\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
|
|
3027
3487
|
}
|
|
3028
3488
|
}
|
|
3029
3489
|
// C1 — continuation post-turn: fallback-persist (hook may not have
|
|
@@ -3062,13 +3522,13 @@ export async function runRepl(opts) {
|
|
|
3062
3522
|
}
|
|
3063
3523
|
}
|
|
3064
3524
|
catch (err) {
|
|
3065
|
-
console.log(
|
|
3525
|
+
console.log('\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
|
|
3066
3526
|
}
|
|
3067
3527
|
}
|
|
3068
3528
|
if (planContinue && planOutcome) {
|
|
3069
3529
|
try {
|
|
3070
3530
|
const { runContinuationChain } = await import('./commands/plan-continue.js');
|
|
3071
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
3531
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
3072
3532
|
await runContinuationChain({
|
|
3073
3533
|
prepared: planContinue.prepared,
|
|
3074
3534
|
marker: planContinue.marker,
|
|
@@ -3077,12 +3537,13 @@ export async function runRepl(opts) {
|
|
|
3077
3537
|
cwd: process.cwd(),
|
|
3078
3538
|
subagentDispatches: planSubagentDispatches,
|
|
3079
3539
|
prompt: inkAwarePrompt,
|
|
3540
|
+
confirm: inkAwareConfirm,
|
|
3080
3541
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
3081
3542
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
3082
3543
|
});
|
|
3083
3544
|
}
|
|
3084
3545
|
catch (err) {
|
|
3085
|
-
console.log(
|
|
3546
|
+
console.log('\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
|
|
3086
3547
|
}
|
|
3087
3548
|
}
|
|
3088
3549
|
// C1 — the phase reached a resolved terminal (revision persisted, chain
|
|
@@ -3122,6 +3583,8 @@ export async function runRepl(opts) {
|
|
|
3122
3583
|
// A4 — price the turn at the model that actually ran it (classic-path
|
|
3123
3584
|
// mirror of the Ink call site above).
|
|
3124
3585
|
turnModel: planModelOverride ?? session.model,
|
|
3586
|
+
// Review M4 — a user cancel with no tokens prints no receipt here too.
|
|
3587
|
+
cancelled: interruptedByUser,
|
|
3125
3588
|
});
|
|
3126
3589
|
// Post-turn roll-forward (Step 7c.2 / 7c.5) — the skill from the turn that
|
|
3127
3590
|
// JUST finished becomes `last_skill` for the next turn's classifier context.
|