@cspeach/cli 1.1.18 → 1.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/dist/agent/anthropic-provider.js +30 -10
- package/dist/agent/cache-keepalive.js +162 -0
- package/dist/agent/cold-prune.js +116 -0
- package/dist/agent/loop.js +804 -159
- package/dist/agent/provider-shape.js +263 -0
- package/dist/agent/providers/ai-hub-provider.js +17 -2
- package/dist/agent/providers/byok-provider.js +33 -3
- package/dist/agent/providers/local-provider.js +12 -2
- package/dist/agent/repair-partial.js +125 -7
- package/dist/agent/summarise-via-provider.js +6 -1
- package/dist/agent/system-prompt.js +38 -0
- package/dist/agent/tool-dispatch.js +66 -21
- package/dist/agent/tool-loading-pin.js +100 -0
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +36 -6
- package/dist/commands/auto-compact.js +33 -16
- package/dist/commands/compact.js +49 -11
- package/dist/commands/config-set.js +16 -3
- package/dist/commands/config-show.js +18 -5
- package/dist/commands/cost.js +54 -35
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-audit.js +1 -0
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +70 -5
- package/dist/cost/cost-log.js +62 -2
- package/dist/cost/pricing.js +11 -6
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lib/spill-labels.js +13 -0
- package/dist/lock-contention.js +2 -2
- package/dist/models/resolve.js +93 -2
- package/dist/models/server-config.js +158 -3
- package/dist/one-shot.js +150 -45
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/image-attachments.js +15 -2
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +100 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +154 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +269 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +55 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/current-transport.js +13 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +43 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +863 -339
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/repin-model.js +18 -0
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +84 -28
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/preamble.js +75 -0
- package/dist/skills/source-manifest.js +11 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-read.js +11 -1
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/result-spill.js +238 -0
- package/dist/tools/sap-read.js +74 -15
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +39 -12
- package/dist/tools/subagent/adt-serial.js +33 -0
- package/dist/tools/subagent/agent_run.js +2 -0
- package/dist/tools/subagent/read_agent.js +178 -0
- package/dist/tools/subagent/reader-prompt.js +48 -0
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +26 -19
- package/dist/tools/tool-loading.js +255 -0
- package/dist/tools/tool-output-read.js +117 -0
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +39 -8
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +2 -1
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +65 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +12 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
package/dist/repl.js
CHANGED
|
@@ -6,37 +6,60 @@ 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
|
-
import { loadServerModelConfig } from './models/server-config.js';
|
|
39
|
+
import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
|
|
40
|
+
import { repinResumedSessionModel } from './session/repin-model.js';
|
|
25
41
|
import { computeCost } from './cost/pricing.js';
|
|
26
|
-
import {
|
|
42
|
+
import { formatSessionSpendValue } from './repl/session-spend-line.js';
|
|
43
|
+
import { formatGoodbye } from './renderer/goodbye.js';
|
|
27
44
|
import { globalCredits } from './cost/credits.js';
|
|
28
45
|
import { initCredits } from './cost/credits-wire.js';
|
|
29
46
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
30
47
|
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
31
48
|
import { runTurn } from './agent/loop.js';
|
|
49
|
+
import { IdleKeepalive } from './agent/cache-keepalive.js';
|
|
32
50
|
import { renderTurnError } from './agent/turn-error-ux.js';
|
|
51
|
+
import { runRerouteTurn } from './repl/reroute-turn.js';
|
|
33
52
|
// BUG-2b (2026-07-05 live smoke) — pure (no-Ink) modules, safe to import
|
|
34
53
|
// statically even though App itself is lazy-loaded to keep Ink out of
|
|
35
54
|
// classic mode. Used by the single-flight dispatch guard below.
|
|
36
55
|
import { turnStatusEmitter } from './ui/turn-status-emitter.js';
|
|
56
|
+
import { decideInterrupt, createPendingTurnCancel, pendingCancelLine, queuedWorkLabel, autoDispatchDecision, settleWithin, EXIT_TAIL_CAP_MS } from './ui/interrupt.js';
|
|
57
|
+
import { setTraceEmitter } from './renderer/trace.js';
|
|
37
58
|
import { enqueueSteering } from './agent/steering-queue.js';
|
|
38
59
|
import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
|
|
60
|
+
import { pipedRoutingPick } from './router/piped-routing.js';
|
|
39
61
|
import { decideRouting } from './router/routing-decision.js';
|
|
62
|
+
import { routingFailureFromError, routingFailureFromResult, routingFailureNotice, routingFailureOutcome, routingFailureText } from './router/routing-failure.js';
|
|
40
63
|
import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
|
|
41
64
|
import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
|
|
42
65
|
import { globalStore as sapStateStore } from './ui/sap-state-store.js';
|
|
@@ -51,6 +74,9 @@ import { approvalEmitter } from './ui/approval-emitter.js';
|
|
|
51
74
|
import { collectRewindCandidates } from './rewind/candidates.js';
|
|
52
75
|
import { executeRestore, runClassicRewind } from './rewind/cli.js';
|
|
53
76
|
import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
|
|
77
|
+
import { pipeRevivedStdin } from './repl/reset-tty-stdin.js';
|
|
78
|
+
import { logsDir } from './config/paths.js';
|
|
79
|
+
import { noticeLogPathFor, pruneNoticeLogs, setNoticeLogPath } from './renderer/notice-log.js';
|
|
54
80
|
import { maybePromptWorkspaceMigration, maybeOfferAbapforgeMigration } from './projects/migration.js';
|
|
55
81
|
import { findProjectRoot } from './projects/workspace.js';
|
|
56
82
|
import { slashCompleter } from './repl/slash-completer.js';
|
|
@@ -81,19 +107,26 @@ function prefillReadline(rl, text) {
|
|
|
81
107
|
import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
|
|
82
108
|
import { InkStdinPasteGuard } from './repl/ink-stdin-guard.js';
|
|
83
109
|
import { printHelp } from './commands/help.js';
|
|
84
|
-
import { shouldUseInk,
|
|
110
|
+
import { shouldUseInk, setOneShotOverride } from './renderer/tty.js';
|
|
111
|
+
import { applyStartupRendering } from './renderer/rendering-mode.js';
|
|
85
112
|
import { createClassicOutputEmitter } from './renderer/notices.js';
|
|
86
113
|
import { saveConfig } from './config/loader.js';
|
|
87
114
|
import { EventEmitter } from 'node:events';
|
|
115
|
+
import { existsSync } from 'node:fs';
|
|
116
|
+
import { join } from 'node:path';
|
|
117
|
+
import { streamsRoot } from './agent/turn-stream.js';
|
|
88
118
|
import { parseAndValidateReroute } from './commands/reroute.js';
|
|
119
|
+
import { emitPlanTurnEnd } from './repl/plan-turn-end.js';
|
|
89
120
|
import { SKILL_CATALOG } from './skill-catalog.js';
|
|
90
|
-
import { replPreviewHook } from './repl/update-method-preview-hook.js';
|
|
121
|
+
import { replPreviewHook, inkPreviewHook } from './repl/update-method-preview-hook.js';
|
|
91
122
|
import { getCurrentTransport, handleTransportCommand } from './repl/current-transport.js';
|
|
92
123
|
import { matchSafetyModeCommand } from './repl/safety-mode-state.js';
|
|
93
124
|
import { getEffectiveWriteMode, runModeCommand } from './repl/mode-cycle.js';
|
|
94
125
|
import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-menu.js';
|
|
95
126
|
import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
|
|
96
|
-
import {
|
|
127
|
+
import { createRestoreGuard } from './repl/restore-guard.js';
|
|
128
|
+
import { gracefulExit } from './lib/graceful-exit.js';
|
|
129
|
+
import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
|
|
97
130
|
import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
|
|
98
131
|
import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
|
|
99
132
|
// Side-effect imports so tool registrations happen before listTools() is called.
|
|
@@ -106,6 +139,8 @@ import './tools/ask-question.js';
|
|
|
106
139
|
import './tools/dispatch-skill.js';
|
|
107
140
|
import './tools/todo.js';
|
|
108
141
|
import './tools/filesystem/file-read.js';
|
|
142
|
+
import './tools/tool-output-read.js';
|
|
143
|
+
import { pruneSpillFiles } from './tools/result-spill.js';
|
|
109
144
|
import './tools/filesystem/read-document.js';
|
|
110
145
|
import './tools/filesystem/file-edit.js';
|
|
111
146
|
import './tools/filesystem/file-write.js';
|
|
@@ -127,7 +162,7 @@ import './tools/subagent/background_run.js';
|
|
|
127
162
|
import './tools/subagent/monitor_emit.js';
|
|
128
163
|
import './tools/subagent/schedule_draft_create.js';
|
|
129
164
|
import './tools/subagent/agent_run.js';
|
|
130
|
-
|
|
165
|
+
import './tools/subagent/read_agent.js';
|
|
131
166
|
const HISTORY_SIZE = 500;
|
|
132
167
|
// Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
|
|
133
168
|
// the single writer shared with the Ink Footer (Task 8, ux-wave1).
|
|
@@ -228,6 +263,15 @@ function isAckContinuation(trimmed, sessionSkill) {
|
|
|
228
263
|
* shell shows only the startup banner + a bare "session saved" line,
|
|
229
264
|
* which feels like everything they just did vanished.
|
|
230
265
|
*/
|
|
266
|
+
/** `~/.cspeach/streams/<id>/` when that folder exists, else null (fix round 3, item 11b). */
|
|
267
|
+
function existingStreamDir(sessionId) {
|
|
268
|
+
try {
|
|
269
|
+
return existsSync(join(streamsRoot(), sessionId)) ? `~/.cspeach/streams/${sessionId}/` : null;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
231
275
|
function printGoodbye(session) {
|
|
232
276
|
const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
|
|
233
277
|
const sessionCost = computeCost(session.model, {
|
|
@@ -240,16 +284,18 @@ function printGoodbye(session) {
|
|
|
240
284
|
(session.usage.output_tokens ?? 0) +
|
|
241
285
|
(session.usage.cache_read_input_tokens ?? 0) +
|
|
242
286
|
(session.usage.cache_creation_input_tokens ?? 0);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
287
|
+
for (const line of formatGoodbye({
|
|
288
|
+
sessionId: session.id,
|
|
289
|
+
turns: userTurns,
|
|
290
|
+
lastSkill: session.skill ?? null,
|
|
291
|
+
tokens: totalTokens,
|
|
292
|
+
spend: formatSessionSpendValue(sessionCost, globalCredits.snapshot()),
|
|
293
|
+
logDir: existingStreamDir(session.id),
|
|
294
|
+
// Round 6 review I1 — gate on the file: a start-then-quit (or a double
|
|
295
|
+
// Ctrl+C in the first turn before it saved) has no session to resume.
|
|
296
|
+
saved: sessionSaved(session),
|
|
297
|
+
}))
|
|
298
|
+
console.log(line);
|
|
253
299
|
}
|
|
254
300
|
function isBareAck(trimmed) {
|
|
255
301
|
const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
|
|
@@ -308,7 +354,29 @@ function buildCoachingParams(result, prompt, preSelect, lastSkill) {
|
|
|
308
354
|
};
|
|
309
355
|
}
|
|
310
356
|
export async function runRepl(opts) {
|
|
311
|
-
|
|
357
|
+
applyColorMode(); // NO_COLOR (piece 2 §1.2) — idempotent; main() already did it for the CLI path
|
|
358
|
+
// 2026-09-26 — the look must be known before the first startup line prints.
|
|
359
|
+
// A config read failure keeps the default look; the real loadConfig below
|
|
360
|
+
// still runs and reports errors exactly as before.
|
|
361
|
+
try {
|
|
362
|
+
const early = await loadConfig();
|
|
363
|
+
setLook(early.ui.look);
|
|
364
|
+
setBackground(early.ui.background);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
setLook(DEFAULT_CONFIG.ui.look);
|
|
368
|
+
}
|
|
369
|
+
// Piece 2 §10: the account folds into the panels brand line; "!" notices
|
|
370
|
+
// collect here and print last. Owner decision 2026-09-27: the panels brand
|
|
371
|
+
// line prints FIRST (no blank terminal during the sign-in check and the
|
|
372
|
+
// rest of startup); the sign-in completes the same line below. Legacy keeps
|
|
373
|
+
// its pinned banner exactly as before.
|
|
374
|
+
const startupNotices = [];
|
|
375
|
+
if (getLook() !== 'panels')
|
|
376
|
+
printStartupBanner();
|
|
377
|
+
const earlyBrand = getLook() === 'panels'
|
|
378
|
+
? startEarlyBrand({ version: CLI_VERSION, tty: process.stdout.isTTY === true, write: (s) => { process.stdout.write(s); } })
|
|
379
|
+
: undefined;
|
|
312
380
|
// v0.6 — surface interrupted sessions at startup so the developer doesn't
|
|
313
381
|
// have to remember the session id from the crash UX. Like `git stash list`
|
|
314
382
|
// for resilience: if you have any unfinished work, you see it immediately.
|
|
@@ -318,8 +386,10 @@ export async function runRepl(opts) {
|
|
|
318
386
|
const { countInterruptedSessions } = await import('./session/store.js');
|
|
319
387
|
const n = await countInterruptedSessions();
|
|
320
388
|
if (n > 0) {
|
|
321
|
-
|
|
322
|
-
|
|
389
|
+
if (getLook() === 'panels')
|
|
390
|
+
startupNotices.push(panelsInterruptedNote(n) + '\n');
|
|
391
|
+
else
|
|
392
|
+
console.log(formatAttention({ text: `You have ${n} interrupted session${n === 1 ? '' : 's'}`, next: 'cspeach --resume (picks the right one from a list)' }));
|
|
323
393
|
}
|
|
324
394
|
}
|
|
325
395
|
catch { /* startup banner is best-effort */ }
|
|
@@ -331,7 +401,7 @@ export async function runRepl(opts) {
|
|
|
331
401
|
// flag is process-scoped, so this prints at most once per CLI launch.
|
|
332
402
|
try {
|
|
333
403
|
const { isFlagOn } = await import('./agent/maybe-build-project-context.js');
|
|
334
|
-
if (isFlagOn()) {
|
|
404
|
+
if (isFlagOn() && getLook() !== 'panels') {
|
|
335
405
|
console.log(chalk.cyan(`[project-context] enabled (cwd=${process.cwd()})`));
|
|
336
406
|
console.log(chalk.dim(' unset CSPEACH_PROJECT_CONTEXT to disable; run `cspeach analyze project-context` later to compare.\n'));
|
|
337
407
|
}
|
|
@@ -341,6 +411,7 @@ export async function runRepl(opts) {
|
|
|
341
411
|
// and render the verbose login banner. Falls through to the legacy keytar
|
|
342
412
|
// flow below when the file is absent (back-compat for pre-`cspeach login`
|
|
343
413
|
// users).
|
|
414
|
+
let signIn;
|
|
344
415
|
try {
|
|
345
416
|
const authFile = await readAuthFile();
|
|
346
417
|
if (authFile) {
|
|
@@ -348,25 +419,44 @@ export async function runRepl(opts) {
|
|
|
348
419
|
const meResult = await fetchMe({ proxyUrl: cfgEarly.proxy_url, apiKey: authFile.apiKey });
|
|
349
420
|
if (!meResult.ok) {
|
|
350
421
|
if (meResult.status === 401) {
|
|
351
|
-
|
|
422
|
+
earlyBrand?.close();
|
|
423
|
+
console.log(formatFailure({ what: 'Your CSPeach sign-in is no longer valid', next: 'cspeach login' }));
|
|
352
424
|
return 1;
|
|
353
425
|
}
|
|
354
|
-
|
|
426
|
+
const note = formatInfo(`Could not refresh your account (${meResult.error})`);
|
|
427
|
+
if (getLook() === 'panels')
|
|
428
|
+
startupNotices.push(note);
|
|
429
|
+
else
|
|
430
|
+
console.log(note.trimEnd());
|
|
355
431
|
}
|
|
356
432
|
else {
|
|
357
433
|
const banner = formatLoginBanner(meResult.profile);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
434
|
+
if (getLook() === 'panels') {
|
|
435
|
+
signIn = signInFragment(meResult.profile);
|
|
436
|
+
if (banner.warningLine)
|
|
437
|
+
startupNotices.push(formatAttention({ text: banner.warningLine }));
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
console.log(banner.line);
|
|
441
|
+
if (banner.warningLine)
|
|
442
|
+
console.log(formatAttention({ text: banner.warningLine }).trimEnd());
|
|
443
|
+
}
|
|
361
444
|
}
|
|
362
445
|
}
|
|
363
446
|
}
|
|
364
447
|
catch { /* banner is best-effort — never block REPL startup */ }
|
|
448
|
+
earlyBrand?.finish(signIn);
|
|
365
449
|
const store = await getStore();
|
|
366
|
-
|
|
367
|
-
printKeytarFallbackBanner();
|
|
368
|
-
}
|
|
450
|
+
const keychainMissing = !(await store.isKeychainAvailable());
|
|
369
451
|
let apiKey = await store.get();
|
|
452
|
+
if (keychainMissing) {
|
|
453
|
+
// Panels: a "!" line with the other notices — unless the API key prompt is
|
|
454
|
+
// about to open, which needs the explanation right now.
|
|
455
|
+
if (getLook() === 'panels' && apiKey)
|
|
456
|
+
startupNotices.push(keychainFallbackNotice());
|
|
457
|
+
else
|
|
458
|
+
printKeytarFallbackBanner();
|
|
459
|
+
}
|
|
370
460
|
if (!apiKey) {
|
|
371
461
|
const entered = await promptPassword({
|
|
372
462
|
message: 'Paste your CSPeach API key (from invite email):',
|
|
@@ -376,19 +466,40 @@ export async function runRepl(opts) {
|
|
|
376
466
|
apiKey = entered;
|
|
377
467
|
}
|
|
378
468
|
let cfg = await loadConfig();
|
|
379
|
-
|
|
380
|
-
|
|
469
|
+
setLook(cfg.ui.look);
|
|
470
|
+
// Apply the rendering mode BEFORE any shouldUseInk() call. This is the
|
|
471
|
+
// interactive entry point, so it is never one-shot whatever argv holds. A
|
|
472
|
+
// stored `classic` is retiring: read as auto, rewritten once, one dim notice
|
|
473
|
+
// (CSPEACH_UI=classic is the escape hatch) — see renderer/rendering-mode.ts.
|
|
474
|
+
setOneShotOverride(false);
|
|
475
|
+
const classicRetiringNotice = await applyStartupRendering(cfg, saveConfig);
|
|
476
|
+
if (classicRetiringNotice) {
|
|
477
|
+
if (getLook() === 'panels')
|
|
478
|
+
startupNotices.push(classicRetiringNotice);
|
|
479
|
+
else
|
|
480
|
+
process.stdout.write(classicRetiringNotice);
|
|
481
|
+
}
|
|
482
|
+
setBackground(cfg.ui.background);
|
|
381
483
|
// Standalone (2026-09-16) — run with no SAP system at all. Decided BEFORE
|
|
382
484
|
// the onboarding wizard: someone who asked for --no-sap (or persisted
|
|
383
485
|
// no_sap) must never be pushed through the SAP setup they don't have.
|
|
384
486
|
// Having aliases configured does NOT override the request.
|
|
385
487
|
// A session that WAS standalone stays standalone on `--resume`: its
|
|
386
488
|
// sap_system is the reserved 'none' alias, which has no entry in cfg.sap.
|
|
387
|
-
|
|
388
|
-
|
|
489
|
+
// Round 6 item 2 — …unless an explicit --sap-alias asks for a SAP system:
|
|
490
|
+
// then the flag wins and the resumed session moves to it.
|
|
491
|
+
const standaloneDecision = resumeStandaloneDecision({
|
|
492
|
+
requestedStandalone: resolveStandalone(cfg, opts?.noSap === true),
|
|
493
|
+
resumedSapSystem: opts?.resumedSession?.sap_system,
|
|
494
|
+
sapAlias: opts?.sapAlias,
|
|
495
|
+
standaloneSource: opts?.noSap === true ? '--no-sap' : 'no_sap setting',
|
|
496
|
+
});
|
|
497
|
+
let standalone = standaloneDecision.standalone;
|
|
389
498
|
const enterStandalone = async () => {
|
|
390
|
-
|
|
391
|
-
'
|
|
499
|
+
if (getLook() !== 'panels') {
|
|
500
|
+
console.log(chalk.dim('\nStandalone mode: no SAP system connected — skills work from pasted code. ' +
|
|
501
|
+
'Connect later with: cspeach config add-sap'));
|
|
502
|
+
}
|
|
392
503
|
// Ask the profile questions ONCE — they become the <sap_system> element
|
|
393
504
|
// every skill reads, so without them a design would target "unknown".
|
|
394
505
|
if (!cfg.standalone) {
|
|
@@ -421,11 +532,22 @@ export async function runRepl(opts) {
|
|
|
421
532
|
cfg = await loadConfig();
|
|
422
533
|
}
|
|
423
534
|
}
|
|
424
|
-
// model-governance step 2d —
|
|
425
|
-
//
|
|
426
|
-
// fetch
|
|
427
|
-
//
|
|
428
|
-
|
|
535
|
+
// model-governance step 2d — fetch the served model-config (managed mode
|
|
536
|
+
// only). Task 3 (2026-09-27): AWAITED, but startup waits at most 1.5 s — a
|
|
537
|
+
// slower fetch aborts and falls back to a fresh disk cache, else the
|
|
538
|
+
// built-ins. The session model is resolved ONCE at newSession below and
|
|
539
|
+
// pinned, so call 0 and call 1 never run on different models (a switch
|
|
540
|
+
// forces a full cache rewrite). Precedence: env > local config > server >
|
|
541
|
+
// built-in. Never throws.
|
|
542
|
+
// Task 12 — the customer layer (/v1/me/model-config, every mode with a
|
|
543
|
+
// key) loads in parallel under the SAME 1.5 s cap: no extra wait. Final
|
|
544
|
+
// precedence: env > local > customer > server > built-in. Fails open.
|
|
545
|
+
await Promise.all([
|
|
546
|
+
loadServerModelConfig(cfg, { timeoutMs: 1500 }),
|
|
547
|
+
loadCustomerModelConfig(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
|
|
548
|
+
// Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
|
|
549
|
+
fetchToolLoadingMode(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
|
|
550
|
+
]);
|
|
429
551
|
// Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
|
|
430
552
|
// A managed customer is billed in credits at a multiplier we do not
|
|
431
553
|
// disclose, so the dollar figures (our Anthropic COGS) must never reach
|
|
@@ -444,12 +566,20 @@ export async function runRepl(opts) {
|
|
|
444
566
|
// identity, so print here exactly as before (byte-identical output); when
|
|
445
567
|
// any alias declares a role, defer the banner until the alias is resolved.
|
|
446
568
|
const anyRoleConfigured = Object.values(cfg.sap).some((s) => s.role !== undefined);
|
|
569
|
+
const advisoryNotice = (capped) => formatAttention({
|
|
570
|
+
text: 'Write mode: advisory',
|
|
571
|
+
fact: `writes are proposed, never executed${capped}`,
|
|
572
|
+
// The config hint only makes sense when config CAUSED advisory.
|
|
573
|
+
next: capped === '' ? 'cspeach config set write_mode approval-gated' : undefined,
|
|
574
|
+
});
|
|
447
575
|
if (!anyRoleConfigured) {
|
|
448
576
|
if (cfg.write_mode === 'advisory-only') {
|
|
449
|
-
|
|
450
|
-
|
|
577
|
+
if (getLook() === 'panels')
|
|
578
|
+
startupNotices.push(advisoryNotice(''));
|
|
579
|
+
else
|
|
580
|
+
console.log(advisoryNotice(''));
|
|
451
581
|
}
|
|
452
|
-
else {
|
|
582
|
+
else if (getLook() !== 'panels') {
|
|
453
583
|
console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
|
|
454
584
|
}
|
|
455
585
|
}
|
|
@@ -460,15 +590,28 @@ export async function runRepl(opts) {
|
|
|
460
590
|
// visibly: the toggle is persisted, not re-consented each session, so the user
|
|
461
591
|
// must see that local file access + command execution are live this session.
|
|
462
592
|
// Printed to stdout here (before any Ink frame mounts).
|
|
593
|
+
// 2026-09-26 — panels folds this announce into the one status line below.
|
|
594
|
+
// 2026-09-26 (I1) — the panels status line must say WHICH tools are live:
|
|
595
|
+
// local_build = file tools + shell command execution; standalone alone =
|
|
596
|
+
// file tools writing to ./cspeach-out.
|
|
597
|
+
let localTools = 'none';
|
|
463
598
|
{
|
|
464
599
|
const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
600
|
+
localTools = announce ? 'files+commands' : standalone ? 'files' : 'none';
|
|
601
|
+
if (getLook() === 'panels') {
|
|
602
|
+
// The side effect (turning the file tools on) must still happen.
|
|
603
|
+
if (!announce && standalone)
|
|
604
|
+
applyStandaloneFileTools(process.cwd());
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
if (announce)
|
|
608
|
+
console.log(chalk.dim(announce + '\n'));
|
|
609
|
+
// 2026-09-17 — standalone writes its outputs to files by contract, so the
|
|
610
|
+
// file tools must be on without a separate local_build step (the RAP
|
|
611
|
+
// stack landed in the transcript with "There are no files" otherwise).
|
|
612
|
+
else if (standalone)
|
|
613
|
+
console.log(chalk.dim(applyStandaloneFileTools(process.cwd()) + '\n'));
|
|
614
|
+
}
|
|
472
615
|
}
|
|
473
616
|
const aliases = Object.keys(cfg.sap);
|
|
474
617
|
// C.1 — --sap-alias flag override skips the interactive picker
|
|
@@ -479,13 +622,17 @@ export async function runRepl(opts) {
|
|
|
479
622
|
selectedAlias = STANDALONE_ALIAS;
|
|
480
623
|
}
|
|
481
624
|
else if (opts?.sapAlias) {
|
|
482
|
-
|
|
625
|
+
const note = aliasSourceNote('flag', opts.sapAlias);
|
|
626
|
+
if (note !== null)
|
|
627
|
+
console.log(note);
|
|
483
628
|
selectedAlias = opts.sapAlias;
|
|
484
629
|
}
|
|
485
630
|
else if (opts?.resumedSession?.sap_system) {
|
|
486
631
|
// v0.6 — resumed session knows its SAP. Skip the picker.
|
|
487
632
|
selectedAlias = opts.resumedSession.sap_system;
|
|
488
|
-
|
|
633
|
+
const note = aliasSourceNote('resume', selectedAlias);
|
|
634
|
+
if (note !== null)
|
|
635
|
+
console.log(note);
|
|
489
636
|
}
|
|
490
637
|
else {
|
|
491
638
|
selectedAlias =
|
|
@@ -507,25 +654,35 @@ export async function runRepl(opts) {
|
|
|
507
654
|
? ` (capped by sap.${selectedAlias}.role = ${aliasRole})`
|
|
508
655
|
: '';
|
|
509
656
|
if (startupWriteMode === 'advisory-only') {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
console.log(
|
|
515
|
-
}
|
|
516
|
-
else {
|
|
517
|
-
console.log('');
|
|
518
|
-
}
|
|
657
|
+
// A role-capped advisory can't be lifted by a config change (no Next:).
|
|
658
|
+
if (getLook() === 'panels')
|
|
659
|
+
startupNotices.push(advisoryNotice(capped));
|
|
660
|
+
else
|
|
661
|
+
console.log(advisoryNotice(capped));
|
|
519
662
|
}
|
|
520
|
-
else {
|
|
663
|
+
else if (getLook() !== 'panels') {
|
|
521
664
|
console.log(chalk.gray(`Write mode: ${startupWriteMode}${capped}\n`));
|
|
522
665
|
}
|
|
523
666
|
}
|
|
524
667
|
// Standalone: the offline stand-in rejects every call with an actionable
|
|
525
668
|
// message. This is the single construction site the AdtClient cast lives at.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
669
|
+
let adt;
|
|
670
|
+
if (standalone) {
|
|
671
|
+
adt = createOfflineAdtClient();
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
try {
|
|
675
|
+
adt = new AdtClient(await getConnection(selectedAlias, cfg.sap[selectedAlias]));
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
// Dry run 2026-09-25: unreachable SAP host → one clear line, not a stack.
|
|
679
|
+
const code = unreachableCode(err);
|
|
680
|
+
if (!code)
|
|
681
|
+
throw err;
|
|
682
|
+
console.error(unreachableMessage(selectedAlias, cfg.sap[selectedAlias].host, cfg.sap[selectedAlias].port, code));
|
|
683
|
+
return 1;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
529
686
|
// Phase A #3 (2026-05-16) — sidebar SAP-state wire, trigger 1 of 4.
|
|
530
687
|
// Push system + user the moment we know the alias. Before this commit
|
|
531
688
|
// the sidebar showed "no system / user: —" forever because the store
|
|
@@ -541,15 +698,49 @@ export async function runRepl(opts) {
|
|
|
541
698
|
system: standalone ? 'standalone' : selectedAlias,
|
|
542
699
|
user: standalone ? '' : cfg.sap[selectedAlias].username,
|
|
543
700
|
writeMode: startupWriteMode,
|
|
701
|
+
standalone,
|
|
544
702
|
});
|
|
545
703
|
// v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
|
|
546
704
|
// preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
|
|
547
705
|
// turnInterrupted flag from the prior run. Skipping newSession() avoids
|
|
548
706
|
// overwriting the saved file with an empty fresh one.
|
|
707
|
+
// Task 3 fix I1 — except the model: a resumed session re-resolves it ONCE
|
|
708
|
+
// through the same role resolution new sessions use (after the awaited
|
|
709
|
+
// served config), marks it 'predicted', saves, then stays pinned. Every
|
|
710
|
+
// resume entry (picker, --resume <id>) lands here via session/resume.ts.
|
|
549
711
|
const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg));
|
|
550
|
-
|
|
551
|
-
|
|
712
|
+
// Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
|
|
713
|
+
let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
|
|
714
|
+
// Round 7 item 8c — the footer shows a resumed session's spend at once.
|
|
715
|
+
if (opts?.resumedSession)
|
|
716
|
+
seedFooterSpend(session);
|
|
717
|
+
// Round 6 item 4b — a new session is written by its first turn (the loop
|
|
718
|
+
// saves after every round); a start-then-quit no longer leaves a 0-turn file.
|
|
719
|
+
// Round 6 item 2 + review I3 — an explicit --sap-alias moved a resumed
|
|
720
|
+
// session (standalone → SAP, or to another SAP system): update + save it.
|
|
721
|
+
await applySystemSwitch({
|
|
722
|
+
session,
|
|
723
|
+
decision: { ...standaloneDecision, standalone },
|
|
724
|
+
selectedAlias,
|
|
725
|
+
save: saveSession,
|
|
726
|
+
notify: (line) => {
|
|
727
|
+
if (getLook() === 'panels')
|
|
728
|
+
startupNotices.push(line);
|
|
729
|
+
else
|
|
730
|
+
console.log(line.trimEnd());
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
// Merge (cost branch Task 3 + UI round 6) — a resumed session re-pins its
|
|
734
|
+
// model from the current config (after any --sap-alias switch above).
|
|
735
|
+
if (opts?.resumedSession) {
|
|
736
|
+
await repinResumedSessionModel(session, cfg);
|
|
552
737
|
}
|
|
738
|
+
// Live fix 2026-09-26 — notices cut to one screen line keep their full text here.
|
|
739
|
+
// Review M6: sanitised id; notice logs older than 14 days are collected.
|
|
740
|
+
pruneNoticeLogs(logsDir());
|
|
741
|
+
// Task 17 — saved large tool outputs (tool-*.txt) older than 14 days go too.
|
|
742
|
+
pruneSpillFiles();
|
|
743
|
+
setNoticeLogPath(noticeLogPathFor(logsDir(), session.id));
|
|
553
744
|
// License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
|
|
554
745
|
// inference, so without this anyone who never logged in could use the bundled
|
|
555
746
|
// skills untracked. Require a valid CSPeach login before the REPL starts.
|
|
@@ -569,30 +760,70 @@ export async function runRepl(opts) {
|
|
|
569
760
|
throw new Error('No API key configured');
|
|
570
761
|
return key;
|
|
571
762
|
});
|
|
763
|
+
// Task 15 — keep the prompt cache warm while the user is idle between
|
|
764
|
+
// turns: armed after a turn, disarmed on the next submit and on exit;
|
|
765
|
+
// `[keepalive] idle_pings` per idle period (managed / BYOK only).
|
|
766
|
+
const idleKeepalive = new IdleKeepalive({
|
|
767
|
+
provider,
|
|
768
|
+
maxPings: () => (cfg.keepalive?.enabled === false ? 0 : (cfg.keepalive?.idle_pings ?? 4)),
|
|
769
|
+
});
|
|
770
|
+
// Panels status line — writeMode is the same EFFECTIVE (role-clamped) mode
|
|
771
|
+
// the legacy line prints.
|
|
772
|
+
const panelsStatus = {
|
|
773
|
+
system: selectedAlias,
|
|
774
|
+
standalone,
|
|
775
|
+
tools: localTools,
|
|
776
|
+
writeMode: startupWriteMode,
|
|
777
|
+
cwd: process.cwd(),
|
|
778
|
+
};
|
|
572
779
|
if (opts?.resumedSession) {
|
|
573
780
|
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
|
-
|
|
781
|
+
// Task 6 (ux-wave2) — session recap (last session · open todos ·
|
|
782
|
+
// unsatisfied plan) so the returning developer is oriented without
|
|
783
|
+
// scrolling. Covers BOTH resume entries — the `cspeach --resume` picker
|
|
784
|
+
// (session/resume.ts) and any direct --resume <id> — since both land here
|
|
785
|
+
// with opts.resumedSession set. Best-effort: the recap never throws, and a
|
|
786
|
+
// workspace-resolution failure just drops the plan fact.
|
|
787
|
+
if (getLook() === 'panels') {
|
|
788
|
+
let recap = { last: null, tasks: null, plan: null };
|
|
789
|
+
try {
|
|
790
|
+
const { buildRecapParts } = await import('./session/recap.js');
|
|
791
|
+
const { resolveWorkspacePath } = await import('./projects/workspace.js');
|
|
792
|
+
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
793
|
+
recap = buildRecapParts(session, process.cwd(), { workspaceDir: recapWs });
|
|
794
|
+
}
|
|
795
|
+
catch { /* recap is best-effort — never block resume */ }
|
|
796
|
+
// Piece 2 §10: same three-line shape. Open tasks show in the checklist
|
|
797
|
+
// (restored from session.todos), so only the plan becomes a "!" line.
|
|
798
|
+
if (session.turnInterrupted) {
|
|
799
|
+
// Round 7 item 8d — a stop the user asked for is not an error.
|
|
800
|
+
startupNotices.push(isUserStopReason(session.turnInterruptedReason)
|
|
801
|
+
? formatAttention({ text: 'Last turn was stopped by you', next: 'type continue to finish where it left off' })
|
|
802
|
+
: formatAttention({ text: 'Last turn was interrupted', fact: readableInterruptReason(session.turnInterruptedReason), next: 'type continue to finish where it left off' }));
|
|
803
|
+
}
|
|
804
|
+
if (recap.plan)
|
|
805
|
+
startupNotices.push(formatAttention({ text: `Unfinished plan ${recap.plan.text}`, next: recap.plan.next }));
|
|
806
|
+
process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsResumeLine(recap.last, turnCount), notices: startupNotices }));
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
console.log(chalk.cyan(`\nResumed session ${session.id} on ${selectedAlias} — ${turnCount} prior turn(s).`));
|
|
810
|
+
if (session.turnInterrupted) {
|
|
811
|
+
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());
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
const { buildRecap } = await import('./session/recap.js');
|
|
815
|
+
const { resolveWorkspacePath } = await import('./projects/workspace.js');
|
|
816
|
+
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
817
|
+
for (const recapLine of buildRecap(session, process.cwd(), { workspaceDir: recapWs })) {
|
|
818
|
+
console.log(chalk.dim(recapLine));
|
|
819
|
+
}
|
|
592
820
|
}
|
|
821
|
+
catch { /* recap is best-effort — never block resume */ }
|
|
822
|
+
console.log(chalk.gray(`Type /exit to quit.\n`));
|
|
593
823
|
}
|
|
594
|
-
|
|
595
|
-
|
|
824
|
+
}
|
|
825
|
+
else if (getLook() === 'panels') {
|
|
826
|
+
process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools), notices: startupNotices }));
|
|
596
827
|
}
|
|
597
828
|
else {
|
|
598
829
|
console.log(chalk.gray(`\nSession ${session.id} on ${selectedAlias}. Type /exit to quit.\n`));
|
|
@@ -625,6 +856,8 @@ export async function runRepl(opts) {
|
|
|
625
856
|
const { App } = await import('./ui/app.js');
|
|
626
857
|
const React = (await import('react')).default;
|
|
627
858
|
const chunkEmitter = new EventEmitter();
|
|
859
|
+
setTraceEmitter(chunkEmitter); // piece 2 §1.5 — answered/decided traces land in <Static>
|
|
860
|
+
const inkPreview = inkPreviewHook(chunkEmitter); // piece 2 §6.3 — Ink sites: diff to scrollback + Apply/Cancel card
|
|
628
861
|
// Phase D6 (2026-05-17) — INLINE Ink mode (no alt-screen).
|
|
629
862
|
//
|
|
630
863
|
// After Laeeq pointed out CC achieves great scrolling without any
|
|
@@ -674,6 +907,50 @@ export async function runRepl(opts) {
|
|
|
674
907
|
// it. When null, we're at idle and should exit on Ctrl+C.
|
|
675
908
|
let currentTurnAbort = null;
|
|
676
909
|
let interruptCount = 0;
|
|
910
|
+
// Review I2/I3 — a cancel asked for before the turn has its controller
|
|
911
|
+
// (routing / prep). Applied at controller creation; cleared when the
|
|
912
|
+
// dispatch ends without a turn.
|
|
913
|
+
const pendingCancel = createPendingTurnCancel();
|
|
914
|
+
// Re-review 2/3 — where a busy dispatch without a controller is, so the
|
|
915
|
+
// cancel line says only what will actually happen. postTurn: the turn's
|
|
916
|
+
// controller has been cleared (saving, audit, next-phase offer);
|
|
917
|
+
// postTurnMayChain: that post-turn work may auto-start a plan phase.
|
|
918
|
+
let postTurn = false;
|
|
919
|
+
let postTurnMayChain = false;
|
|
920
|
+
let cancelAnnouncedPhase = null;
|
|
921
|
+
/** Round 4 item 5 — the in-flight classifier request, while routing. */
|
|
922
|
+
let routingAbort = null;
|
|
923
|
+
const cancelPhase = () => turnStatusEmitter.snapshot().routing ? 'routing' : postTurn ? 'post' : 'prep';
|
|
924
|
+
const canChainNow = () => postTurnMayChain || pendingDispatchAccessor.get() !== null;
|
|
925
|
+
/** Queue a cancel; print its one truthful line the first time. */
|
|
926
|
+
const announcePendingCancel = (exiting) => {
|
|
927
|
+
if (!pendingCancel.request())
|
|
928
|
+
return;
|
|
929
|
+
const phase = cancelPhase();
|
|
930
|
+
cancelAnnouncedPhase = phase;
|
|
931
|
+
// Round 4 item 5 — stop the classifier request (and its spinner) now.
|
|
932
|
+
routingAbort?.abort();
|
|
933
|
+
const line = phase === 'post' && !canChainNow()
|
|
934
|
+
? 'Exiting when this turn has finished saving'
|
|
935
|
+
// Round 4 item 9a — name what is queued (a skill switch is not a plan phase).
|
|
936
|
+
: pendingCancelLine(phase, exiting, queuedWorkLabel(pendingDispatchAccessor.get()));
|
|
937
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo(line));
|
|
938
|
+
};
|
|
939
|
+
/** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
|
|
940
|
+
const autoDispatch = (cmd) => {
|
|
941
|
+
// Round 4 item 9b — the exit is already under way: start nothing new.
|
|
942
|
+
// Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
|
|
943
|
+
const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending }, cmd);
|
|
944
|
+
if (decision.kind === 'skip')
|
|
945
|
+
return;
|
|
946
|
+
if (decision.kind === 'not-started') {
|
|
947
|
+
pendingCancel.clear();
|
|
948
|
+
chunkEmitter.emit('chunk', formatInfo(decision.line));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
chunkEmitter.emit('chunk', formatInfo(`Auto-routed: ${cmd}`));
|
|
952
|
+
setImmediate(() => { void handleSubmit(cmd); });
|
|
953
|
+
};
|
|
677
954
|
// BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
|
|
678
955
|
// owns the single-flight reserve()/release() lifecycle. All re-entrant
|
|
679
956
|
// callers (the setImmediate auto-dispatch / re-audit legs) go through the
|
|
@@ -682,6 +959,10 @@ export async function runRepl(opts) {
|
|
|
682
959
|
const trimmed = line.trim();
|
|
683
960
|
if (!trimmed)
|
|
684
961
|
return;
|
|
962
|
+
// Round 6 review I2 — the resumed skill is offered to the FIRST submit
|
|
963
|
+
// only (slash commands included), then cleared.
|
|
964
|
+
const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
|
|
965
|
+
resumeKeepsSkill = false;
|
|
685
966
|
// Task 8 (ux-wave1) — shared dedup-guarded writer. On the Ink path
|
|
686
967
|
// the Footer has already persisted this submission (flattened), so
|
|
687
968
|
// the consecutive-dup guard makes this a no-op instead of the old
|
|
@@ -694,8 +975,7 @@ export async function runRepl(opts) {
|
|
|
694
975
|
// new turn's content touched with no separator and looked like
|
|
695
976
|
// garbled overlap).
|
|
696
977
|
if (trimmed !== '/exit' && trimmed !== '/quit') {
|
|
697
|
-
chunkEmitter.emit('chunk',
|
|
698
|
-
chalk.cyan(`› ${trimmed}\n`));
|
|
978
|
+
chunkEmitter.emit('chunk', formatPromptEcho(trimmed, process.stdout.columns || 80));
|
|
699
979
|
}
|
|
700
980
|
if (trimmed === '/exit' || trimmed === '/quit') {
|
|
701
981
|
done = true;
|
|
@@ -719,7 +999,9 @@ export async function runRepl(opts) {
|
|
|
719
999
|
// from the JSONL log written each turn. Read-only, no network.
|
|
720
1000
|
if (trimmed === '/cost') {
|
|
721
1001
|
const { runCostCommand } = await import('./commands/cost.js');
|
|
722
|
-
|
|
1002
|
+
// Piece 2: through the chunkEmitter (like /context and /export) — a
|
|
1003
|
+
// console.log here would land above the Ink frame.
|
|
1004
|
+
await runCostCommand(session.id, undefined, (l) => chunkEmitter.emit('chunk', l + '\n'));
|
|
723
1005
|
return;
|
|
724
1006
|
}
|
|
725
1007
|
// Task 4 (ux-wave3) — /export audit [filename]: write the signable
|
|
@@ -764,7 +1046,7 @@ export async function runRepl(opts) {
|
|
|
764
1046
|
if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
|
|
765
1047
|
const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
|
|
766
1048
|
const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
|
|
767
|
-
chunkEmitter.emit('chunk',
|
|
1049
|
+
chunkEmitter.emit('chunk', formatInfo(modeLine));
|
|
768
1050
|
sapStateStore.update({ writeMode: mode });
|
|
769
1051
|
return;
|
|
770
1052
|
}
|
|
@@ -777,8 +1059,8 @@ export async function runRepl(opts) {
|
|
|
777
1059
|
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
778
1060
|
const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
|
|
779
1061
|
const body = recapLines.length === 0
|
|
780
|
-
?
|
|
781
|
-
: recapLines.map((l) =>
|
|
1062
|
+
? formatInfo('Nothing to recap yet').trimEnd()
|
|
1063
|
+
: formatPanel('Recap', recapLines.map((l) => ' ' + paint('dim', sanitizeControlChars(l)))).join('\n');
|
|
782
1064
|
chunkEmitter.emit('chunk', '\n' + body + '\n');
|
|
783
1065
|
return;
|
|
784
1066
|
}
|
|
@@ -794,15 +1076,15 @@ export async function runRepl(opts) {
|
|
|
794
1076
|
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
|
|
795
1077
|
// model-governance step 2d — compact model resolves env > local >
|
|
796
1078
|
// server > built-in (== COMPACTION_MODEL when nothing set).
|
|
797
|
-
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
1079
|
+
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
|
|
798
1080
|
};
|
|
799
1081
|
try {
|
|
800
|
-
chunkEmitter.emit('chunk',
|
|
1082
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Compacting older turns into a summary…'));
|
|
801
1083
|
const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
|
|
802
1084
|
chunkEmitter.emit('chunk', formatCompactResult(result, compactCfg.keep_recent_turns));
|
|
803
1085
|
}
|
|
804
1086
|
catch (err) {
|
|
805
|
-
chunkEmitter.emit('chunk',
|
|
1087
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Compact failed', detail: [err instanceof Error ? err.message : String(err)] }));
|
|
806
1088
|
}
|
|
807
1089
|
return;
|
|
808
1090
|
}
|
|
@@ -812,9 +1094,9 @@ export async function runRepl(opts) {
|
|
|
812
1094
|
if (trimmed === '/files') {
|
|
813
1095
|
try {
|
|
814
1096
|
const { openFilePicker } = await import('./repl/file-picker.js');
|
|
815
|
-
const result = await openFilePicker();
|
|
1097
|
+
const result = await openFilePicker({ ink: true });
|
|
816
1098
|
if (result.kind === 'command') {
|
|
817
|
-
chunkEmitter.emit('chunk',
|
|
1099
|
+
chunkEmitter.emit('chunk', formatInfo(result.command));
|
|
818
1100
|
}
|
|
819
1101
|
else if (result.kind === 'empty') {
|
|
820
1102
|
// Empty workspace — print the listing so the user sees the
|
|
@@ -828,7 +1110,7 @@ export async function runRepl(opts) {
|
|
|
828
1110
|
// result.kind === 'cancelled' — print nothing.
|
|
829
1111
|
}
|
|
830
1112
|
catch (err) {
|
|
831
|
-
chunkEmitter.emit('chunk',
|
|
1113
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not open the file list', detail: [err.message] }));
|
|
832
1114
|
}
|
|
833
1115
|
return;
|
|
834
1116
|
}
|
|
@@ -837,32 +1119,12 @@ export async function runRepl(opts) {
|
|
|
837
1119
|
// lands in Body, not above the React frame.
|
|
838
1120
|
if (trimmed === '/ui' || trimmed.startsWith('/ui ')) {
|
|
839
1121
|
if (!cfg.ui)
|
|
840
|
-
cfg.ui = { rendering: 'auto' };
|
|
1122
|
+
cfg.ui = { rendering: 'auto', look: DEFAULT_CONFIG.ui.look };
|
|
841
1123
|
const arg = trimmed.slice(3).trim().toLowerCase();
|
|
842
1124
|
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
|
-
}
|
|
1125
|
+
if (await handleUiLook(arg, cfg, saveConfig, emit))
|
|
1126
|
+
return;
|
|
1127
|
+
await handleUiRendering(arg, cfg, saveConfig, emit, shouldUseInk() ? 'ink' : 'classic');
|
|
866
1128
|
return;
|
|
867
1129
|
}
|
|
868
1130
|
// /reroute — re-dispatch session.lastUserPrompt to a different skill
|
|
@@ -874,23 +1136,42 @@ export async function runRepl(opts) {
|
|
|
874
1136
|
return;
|
|
875
1137
|
}
|
|
876
1138
|
const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
|
|
877
|
-
chunkEmitter.emit('chunk',
|
|
1139
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo(`Rerouting "${preview}" to /${rr.skill}`));
|
|
878
1140
|
if (rr.warning)
|
|
879
1141
|
chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
|
|
1142
|
+
// Re-review 1 — its own AbortController, wired like the main path, so
|
|
1143
|
+
// Ctrl+C and `/exit` stop a rerouted turn the same way.
|
|
1144
|
+
const rerouteAbort = new AbortController();
|
|
1145
|
+
currentTurnAbort = rerouteAbort;
|
|
1146
|
+
interruptCount = 0;
|
|
1147
|
+
if (pendingCancel.applyTo(rerouteAbort))
|
|
1148
|
+
interruptCount = 1;
|
|
880
1149
|
turnStatusEmitter.start(`/${rr.skill}`);
|
|
881
|
-
|
|
882
|
-
|
|
1150
|
+
// A turn error is rendered into the conversation like the normal turn
|
|
1151
|
+
// path (never an unhandled rejection out of `void handleSubmit`).
|
|
1152
|
+
await runRerouteTurn({
|
|
1153
|
+
run: () => runTurn({
|
|
883
1154
|
provider,
|
|
884
1155
|
userMessage: rr.prompt,
|
|
885
1156
|
skill: rr.skill,
|
|
886
|
-
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook:
|
|
1157
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
887
1158
|
chunkEmitter,
|
|
888
1159
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1160
|
+
autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
|
|
1161
|
+
signal: rerouteAbort.signal,
|
|
1162
|
+
}),
|
|
1163
|
+
session,
|
|
1164
|
+
print: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1165
|
+
signal: rerouteAbort.signal,
|
|
1166
|
+
onFinally: () => {
|
|
1167
|
+
turnStatusEmitter.stop();
|
|
1168
|
+
currentTurnAbort = null;
|
|
1169
|
+
postTurn = true;
|
|
1170
|
+
// P2 fix round 1 — /reroute skips pushPostTurnStatus, so run its
|
|
1171
|
+
// turn-end plan step here (close line / 'close' when finished).
|
|
1172
|
+
emitPlanTurnEnd(session);
|
|
1173
|
+
},
|
|
1174
|
+
});
|
|
894
1175
|
return;
|
|
895
1176
|
}
|
|
896
1177
|
// /new — clear any "awaiting skill answer" state so the next prompt
|
|
@@ -898,7 +1179,11 @@ export async function runRepl(opts) {
|
|
|
898
1179
|
// previous skill's Q&A chain.
|
|
899
1180
|
if (trimmed === '/new' || trimmed === '/reset') {
|
|
900
1181
|
session.awaitingSkillAnswer = false;
|
|
901
|
-
|
|
1182
|
+
// P2 — /new clears the plan checklist (spec §4 lifecycle): the session
|
|
1183
|
+
// list AND the live view, so Ctrl+T / --resume can't bring it back.
|
|
1184
|
+
session.todos = [];
|
|
1185
|
+
todoEmitter.emit('update', []);
|
|
1186
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Fresh start · the next prompt picks a skill again'));
|
|
902
1187
|
return;
|
|
903
1188
|
}
|
|
904
1189
|
// /transport — session current-transport state for Rule 9 (transport isolation)
|
|
@@ -990,63 +1275,119 @@ export async function runRepl(opts) {
|
|
|
990
1275
|
chalk.dim('Type a new question, /new to reset, or /exit to quit.\n'));
|
|
991
1276
|
return;
|
|
992
1277
|
}
|
|
993
|
-
|
|
994
|
-
|
|
1278
|
+
// Round 6 item 4c / review I2 — after a resume, a short follow-up
|
|
1279
|
+
// ("continue", a number) stays in the session's skill; anything else
|
|
1280
|
+
// is classified (applyResumedSkill below).
|
|
1281
|
+
if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
|
|
1282
|
+
chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: session.skill }));
|
|
995
1283
|
skill = session.skill;
|
|
996
1284
|
}
|
|
997
1285
|
else if (isBareAck(trimmed)) {
|
|
998
1286
|
// Bare "y" / "n" / "cancel" with no skill to continue — typing this
|
|
999
1287
|
// after an aborted turn is almost always a stray keystroke, NOT a
|
|
1000
1288
|
// request to start work. Don't burn a classifier round-trip on it.
|
|
1001
|
-
chunkEmitter.emit('chunk',
|
|
1289
|
+
chunkEmitter.emit('chunk', formatInfo('No active skill · type /help or a full prompt'));
|
|
1002
1290
|
return;
|
|
1003
1291
|
}
|
|
1004
1292
|
else {
|
|
1005
1293
|
try {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1294
|
+
// Fix round 3 (item 8) — the status row reads "routing…" for
|
|
1295
|
+
// the whole classifier wait (was a blank screen for up to 16 s).
|
|
1296
|
+
turnStatusEmitter.routingStart();
|
|
1297
|
+
// Round 4 item 5 — Ctrl+C during routing aborts the classifier
|
|
1298
|
+
// request at once (the spinner used to run until it answered,
|
|
1299
|
+
// up to ~17 s). A cancel queued before routing started aborts
|
|
1300
|
+
// it before any request is made.
|
|
1301
|
+
const routingCtl = new AbortController();
|
|
1302
|
+
routingAbort = routingCtl;
|
|
1303
|
+
if (pendingCancel.pending)
|
|
1304
|
+
routingCtl.abort();
|
|
1305
|
+
let c;
|
|
1306
|
+
try {
|
|
1307
|
+
c = await classifyPrompt(prompt, {
|
|
1308
|
+
sap_system: selectedAlias,
|
|
1309
|
+
last_skill: session.last_skill ?? null,
|
|
1310
|
+
last_object: extractedObject ?? session.last_object ?? null,
|
|
1311
|
+
}, { signal: routingCtl.signal });
|
|
1016
1312
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1313
|
+
catch (err) {
|
|
1314
|
+
if (!routingCtl.signal.aborted)
|
|
1315
|
+
throw err;
|
|
1020
1316
|
}
|
|
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));
|
|
1317
|
+
finally {
|
|
1318
|
+
routingAbort = null;
|
|
1319
|
+
turnStatusEmitter.routingEnd();
|
|
1030
1320
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1321
|
+
// Review I2/I3 — cancelled while routing: never start the turn.
|
|
1322
|
+
// One line only (re-review 3): the press already printed it when
|
|
1323
|
+
// it came during routing.
|
|
1324
|
+
if (pendingCancel.pending || c === undefined) {
|
|
1325
|
+
pendingCancel.clear();
|
|
1326
|
+
if (cancelAnnouncedPhase !== 'routing') {
|
|
1327
|
+
chunkEmitter.emit('chunk', formatInfo('Turn cancelled · nothing was sent'));
|
|
1328
|
+
}
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (c.screenshot) {
|
|
1332
|
+
chunkEmitter.emit('chunk', formatInfo(`Screenshot: ${c.screenshot}`));
|
|
1034
1333
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1334
|
+
// Fix 10 (2026-09-26): routing failed mid-skill → stay in the
|
|
1335
|
+
// active skill (one dim line), never the picker. Shared decision
|
|
1336
|
+
// with the classic path (routingFailureOutcome).
|
|
1337
|
+
const failed = routingFailureOutcome(routingFailureFromResult(c), session.skill);
|
|
1338
|
+
if (failed.kind === 'stay') {
|
|
1339
|
+
chunkEmitter.emit('chunk', formatInfo(failed.line));
|
|
1340
|
+
skill = failed.skill;
|
|
1037
1341
|
}
|
|
1038
1342
|
else {
|
|
1039
|
-
|
|
1343
|
+
if (c.offline) {
|
|
1344
|
+
// Remote classify failed twice — local-rules fallback fired.
|
|
1345
|
+
// Tell the user so they know the picker preselection came
|
|
1346
|
+
// from regex, not the LLM, and surface the real cause.
|
|
1347
|
+
// Fix 9: true wording from the failure kind (a timeout is not "offline").
|
|
1348
|
+
chunkEmitter.emit('chunk', formatAttention(routingFailureNotice(routingFailureFromResult(c), c.transport_error)));
|
|
1349
|
+
}
|
|
1350
|
+
// Round 6 review I2 — after a resume the classifier's confident pick
|
|
1351
|
+
// wins; the resumed skill replaces only the "Pick a skill" card.
|
|
1352
|
+
const decision = resumedSkill ? applyResumedSkill(decideRouting(c), resumedSkill) : decideRouting(c);
|
|
1353
|
+
// 2026-05-17 — pass the just-used skill so the picker can
|
|
1354
|
+
// default cursor to it (one-Enter "continue" UX for follow-up
|
|
1355
|
+
// questions). session.skill = immediately-previous turn's
|
|
1356
|
+
// skill, NOT session.last_skill which is from TWO turns ago.
|
|
1357
|
+
const continueSkill = session.skill ?? null;
|
|
1358
|
+
let pickedResult;
|
|
1359
|
+
if (cfg.classifier.safe_mode) {
|
|
1360
|
+
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false, continueSkill));
|
|
1361
|
+
}
|
|
1362
|
+
else if (decision.kind === 'silent') {
|
|
1363
|
+
chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'using', skill: decision.skill }));
|
|
1364
|
+
pickedResult = decision.skill;
|
|
1365
|
+
}
|
|
1366
|
+
else if (decision.kind === 'coach') {
|
|
1367
|
+
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, true, continueSkill));
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, false, continueSkill));
|
|
1371
|
+
}
|
|
1372
|
+
if (pickedResult === PICK_CANCELLED) {
|
|
1373
|
+
chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'cancelled' }));
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
skill = pickedResult;
|
|
1377
|
+
} // end fix-10 fallback branch
|
|
1378
|
+
}
|
|
1379
|
+
catch (err) {
|
|
1380
|
+
const failure = routingFailureFromError(err);
|
|
1381
|
+
const failed = routingFailureOutcome(failure, session.skill);
|
|
1382
|
+
if (failed.kind === 'stay') {
|
|
1383
|
+
// Fix 10: HTTP routing failure mid-skill → stay in the active skill.
|
|
1384
|
+
chunkEmitter.emit('chunk', formatInfo(failed.line));
|
|
1385
|
+
skill = failed.skill;
|
|
1040
1386
|
}
|
|
1041
|
-
|
|
1042
|
-
chunkEmitter.emit('chunk',
|
|
1387
|
+
else {
|
|
1388
|
+
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
1389
|
return;
|
|
1044
1390
|
}
|
|
1045
|
-
skill = pickedResult;
|
|
1046
|
-
}
|
|
1047
|
-
catch (err) {
|
|
1048
|
-
chunkEmitter.emit('chunk', chalk.red(`\n[classifier error] ${err.message}\n`));
|
|
1049
|
-
return;
|
|
1050
1391
|
}
|
|
1051
1392
|
}
|
|
1052
1393
|
}
|
|
@@ -1076,7 +1417,7 @@ export async function runRepl(opts) {
|
|
|
1076
1417
|
});
|
|
1077
1418
|
}
|
|
1078
1419
|
catch (err) {
|
|
1079
|
-
chunkEmitter.emit('chunk',
|
|
1420
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not show the status', detail: [err.message] }));
|
|
1080
1421
|
}
|
|
1081
1422
|
session.last_skill = previousSkill ?? null;
|
|
1082
1423
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
@@ -1108,7 +1449,7 @@ export async function runRepl(opts) {
|
|
|
1108
1449
|
if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
|
|
1109
1450
|
try {
|
|
1110
1451
|
const { preparePlanResume } = await import('./commands/plan-resume.js');
|
|
1111
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1452
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
1112
1453
|
planResume = await preparePlanResume({
|
|
1113
1454
|
body: prompt,
|
|
1114
1455
|
cwd: process.cwd(),
|
|
@@ -1120,11 +1461,12 @@ export async function runRepl(opts) {
|
|
|
1120
1461
|
// resume guard; inkAwarePrompt answers '' in headless = decline).
|
|
1121
1462
|
sessionId: session.id,
|
|
1122
1463
|
prompt: inkAwarePrompt,
|
|
1464
|
+
confirm: inkAwareConfirm,
|
|
1123
1465
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1124
1466
|
});
|
|
1125
1467
|
}
|
|
1126
1468
|
catch (err) {
|
|
1127
|
-
chunkEmitter.emit('chunk',
|
|
1469
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
|
|
1128
1470
|
}
|
|
1129
1471
|
if (!planResume) {
|
|
1130
1472
|
session.last_skill = previousSkill ?? null;
|
|
@@ -1139,7 +1481,7 @@ export async function runRepl(opts) {
|
|
|
1139
1481
|
if (planResume.reauditPhaseId) {
|
|
1140
1482
|
try {
|
|
1141
1483
|
const { runReauditPass } = await import('./commands/plan-chain.js');
|
|
1142
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1484
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
1143
1485
|
await runReauditPass({
|
|
1144
1486
|
savedPath: planResume.path,
|
|
1145
1487
|
phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
|
|
@@ -1152,12 +1494,13 @@ export async function runRepl(opts) {
|
|
|
1152
1494
|
auditMode: planResume.auditMode,
|
|
1153
1495
|
auditFlag: planResume.auditFlag,
|
|
1154
1496
|
prompt: inkAwarePrompt,
|
|
1497
|
+
confirm: inkAwareConfirm,
|
|
1155
1498
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
1156
1499
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1157
1500
|
});
|
|
1158
1501
|
}
|
|
1159
1502
|
catch (err) {
|
|
1160
|
-
chunkEmitter.emit('chunk',
|
|
1503
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan re-audit failed', detail: [err.message] }));
|
|
1161
1504
|
}
|
|
1162
1505
|
session.last_skill = previousSkill ?? null;
|
|
1163
1506
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
@@ -1175,8 +1518,7 @@ export async function runRepl(opts) {
|
|
|
1175
1518
|
}
|
|
1176
1519
|
if (reauditDispatched) {
|
|
1177
1520
|
pendingDispatchAccessor.set(null);
|
|
1178
|
-
|
|
1179
|
-
setImmediate(() => { void handleSubmit(reauditDispatched); });
|
|
1521
|
+
autoDispatch(reauditDispatched);
|
|
1180
1522
|
}
|
|
1181
1523
|
return;
|
|
1182
1524
|
}
|
|
@@ -1188,7 +1530,7 @@ export async function runRepl(opts) {
|
|
|
1188
1530
|
{
|
|
1189
1531
|
const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
|
|
1190
1532
|
if (resetContextForPlanResume(session)) {
|
|
1191
|
-
chunkEmitter.emit('chunk',
|
|
1533
|
+
chunkEmitter.emit('chunk', formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared'));
|
|
1192
1534
|
}
|
|
1193
1535
|
}
|
|
1194
1536
|
planResumeMsgStart = session.messages.length;
|
|
@@ -1244,7 +1586,7 @@ export async function runRepl(opts) {
|
|
|
1244
1586
|
planContinue = { prepared: r.prepared, marker };
|
|
1245
1587
|
planPhaseStartedAt = marker.attemptStartedAt;
|
|
1246
1588
|
planResumeMsgStart = session.messages.length;
|
|
1247
|
-
chunkEmitter.emit('chunk',
|
|
1589
|
+
chunkEmitter.emit('chunk', formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`));
|
|
1248
1590
|
}
|
|
1249
1591
|
else if (r.stale) {
|
|
1250
1592
|
session.plan_in_flight = null; // plan/phase gone for good — marker is noise
|
|
@@ -1271,12 +1613,18 @@ export async function runRepl(opts) {
|
|
|
1271
1613
|
// modes. (Pre-fix this whole block didn't exist in the Ink path —
|
|
1272
1614
|
// sidebar stayed frozen at "0 tok / no turns yet" forever.)
|
|
1273
1615
|
const turnSnapshot = snapshotTurnStart(session);
|
|
1616
|
+
// Round 6 item 4d — the user cancelled this turn (no receipt when it used no tokens).
|
|
1617
|
+
let turnCancelledByUser = false;
|
|
1274
1618
|
// Phase D5 (2026-05-17) — install per-turn abort. onInterrupt
|
|
1275
1619
|
// (Ctrl+C handler) calls currentTurnAbort.abort() while this is
|
|
1276
1620
|
// non-null, which tears the in-flight provider stream down via
|
|
1277
1621
|
// the signal we thread into runTurn.
|
|
1278
1622
|
currentTurnAbort = new AbortController();
|
|
1279
1623
|
interruptCount = 0;
|
|
1624
|
+
// Review I2/I3 — `/exit` or Ctrl+C arrived during prep: stop the turn
|
|
1625
|
+
// at its very start (the press already said "Stopping…").
|
|
1626
|
+
if (pendingCancel.applyTo(currentTurnAbort))
|
|
1627
|
+
interruptCount = 1;
|
|
1280
1628
|
// 2026-06-07 — persistent status row lifecycle. Started here (not in
|
|
1281
1629
|
// loop.ts) so the try/finally GUARANTEES the row clears even when the
|
|
1282
1630
|
// turn throws; loop.ts only narrates activity changes in between.
|
|
@@ -1308,7 +1656,7 @@ export async function runRepl(opts) {
|
|
|
1308
1656
|
if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
|
|
1309
1657
|
setGuardedRunActive(true);
|
|
1310
1658
|
guardedRunFlagSet = true;
|
|
1311
|
-
chunkEmitter.emit('chunk',
|
|
1659
|
+
chunkEmitter.emit('chunk', formatInfo('Guarded mode · auto-approve is off for this run'));
|
|
1312
1660
|
}
|
|
1313
1661
|
// Off-switch (2026-07-06, owner) — announce plainly that this run's
|
|
1314
1662
|
// phases are NOT audited (honesty: off is a choice, never a lie).
|
|
@@ -1320,13 +1668,18 @@ export async function runRepl(opts) {
|
|
|
1320
1668
|
provider,
|
|
1321
1669
|
userMessage: prompt,
|
|
1322
1670
|
skill: skill,
|
|
1323
|
-
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook:
|
|
1671
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1324
1672
|
chunkEmitter,
|
|
1325
1673
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
1326
1674
|
signal: currentTurnAbort.signal,
|
|
1327
1675
|
suppressSaveHook: planResume !== null || planContinue !== null,
|
|
1676
|
+
// Task 19 — auto-compact at turn end, except plan resume/continue
|
|
1677
|
+
// turns (their post-turn persist indexes messages from turn start).
|
|
1678
|
+
autoCompact: planResume === null && planContinue === null,
|
|
1328
1679
|
// A4 — undefined on every non-plan turn (and when tiering is off).
|
|
1329
1680
|
modelOverride: planModelOverride,
|
|
1681
|
+
// Task 9 — a tier-down, never the session role (proxy must not enforce it).
|
|
1682
|
+
modelRole: planModelOverride ? 'audit' : undefined,
|
|
1330
1683
|
// Persist the plan revision the instant its manifest is finalised
|
|
1331
1684
|
// (before the continuation modal) so a Ctrl+C can't lose the phase.
|
|
1332
1685
|
onPlanManifest: planResumeForHook
|
|
@@ -1346,7 +1699,7 @@ export async function runRepl(opts) {
|
|
|
1346
1699
|
planSavedEarly = true;
|
|
1347
1700
|
}
|
|
1348
1701
|
catch (err) {
|
|
1349
|
-
chunkEmitter.emit('chunk',
|
|
1702
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
|
|
1350
1703
|
}
|
|
1351
1704
|
}
|
|
1352
1705
|
: planContinueForHook
|
|
@@ -1373,7 +1726,7 @@ export async function runRepl(opts) {
|
|
|
1373
1726
|
// owns the foreign save / quiet-none handling.
|
|
1374
1727
|
}
|
|
1375
1728
|
catch (err) {
|
|
1376
|
-
chunkEmitter.emit('chunk',
|
|
1729
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
|
|
1377
1730
|
}
|
|
1378
1731
|
}
|
|
1379
1732
|
: undefined,
|
|
@@ -1386,10 +1739,11 @@ export async function runRepl(opts) {
|
|
|
1386
1739
|
// to do. Same block classic mode prints; in-session it leads with
|
|
1387
1740
|
// "type continue" (the partial turn is already saved).
|
|
1388
1741
|
const { renderTurnError } = await import('./agent/turn-error-ux.js');
|
|
1742
|
+
turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
|
|
1389
1743
|
await renderTurnError({
|
|
1390
1744
|
err,
|
|
1391
1745
|
session,
|
|
1392
|
-
userInterrupted:
|
|
1746
|
+
userInterrupted: turnCancelledByUser,
|
|
1393
1747
|
inSession: true,
|
|
1394
1748
|
print: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1395
1749
|
});
|
|
@@ -1397,6 +1751,11 @@ export async function runRepl(opts) {
|
|
|
1397
1751
|
finally {
|
|
1398
1752
|
turnStatusEmitter.stop();
|
|
1399
1753
|
currentTurnAbort = null;
|
|
1754
|
+
// Re-review 2 — from here on a Ctrl+C can only stop an automatic
|
|
1755
|
+
// next plan phase (and only when this is a plan turn or a dispatch
|
|
1756
|
+
// is already queued); otherwise it behaves as idle.
|
|
1757
|
+
postTurn = true;
|
|
1758
|
+
postTurnMayChain = planResume !== null || planContinue !== null;
|
|
1400
1759
|
// Task 6/7/10 — neither the guarded auto_approve override, the
|
|
1401
1760
|
// plan-phase enforcement flag, nor the phase-writes declaration may
|
|
1402
1761
|
// outlive the phase turn. (The deviation flag DOES survive this
|
|
@@ -1439,7 +1798,7 @@ export async function runRepl(opts) {
|
|
|
1439
1798
|
});
|
|
1440
1799
|
}
|
|
1441
1800
|
catch (err) {
|
|
1442
|
-
chunkEmitter.emit('chunk',
|
|
1801
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
|
|
1443
1802
|
}
|
|
1444
1803
|
}
|
|
1445
1804
|
// A1 (defect D23) + Task 6 — harness-owned continuation, now the full
|
|
@@ -1452,7 +1811,7 @@ export async function runRepl(opts) {
|
|
|
1452
1811
|
if (planResume && planOutcome) {
|
|
1453
1812
|
try {
|
|
1454
1813
|
const { runPostPhaseChain } = await import('./commands/plan-chain.js');
|
|
1455
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1814
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
1456
1815
|
await runPostPhaseChain({
|
|
1457
1816
|
savedPath: planOutcome.savedPath,
|
|
1458
1817
|
executedPhaseId: planResume.nextPhaseId,
|
|
@@ -1470,12 +1829,13 @@ export async function runRepl(opts) {
|
|
|
1470
1829
|
// finally; thread the captured value for the report line.
|
|
1471
1830
|
subagentDispatches: planSubagentDispatches,
|
|
1472
1831
|
prompt: inkAwarePrompt,
|
|
1832
|
+
confirm: inkAwareConfirm,
|
|
1473
1833
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
1474
1834
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1475
1835
|
});
|
|
1476
1836
|
}
|
|
1477
1837
|
catch (err) {
|
|
1478
|
-
chunkEmitter.emit('chunk',
|
|
1838
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
|
|
1479
1839
|
}
|
|
1480
1840
|
}
|
|
1481
1841
|
// C1 — continuation post-turn: fallback-persist (hook may not have
|
|
@@ -1514,13 +1874,13 @@ export async function runRepl(opts) {
|
|
|
1514
1874
|
}
|
|
1515
1875
|
}
|
|
1516
1876
|
catch (err) {
|
|
1517
|
-
chunkEmitter.emit('chunk',
|
|
1877
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
|
|
1518
1878
|
}
|
|
1519
1879
|
}
|
|
1520
1880
|
if (planContinue && planOutcome) {
|
|
1521
1881
|
try {
|
|
1522
1882
|
const { runContinuationChain } = await import('./commands/plan-continue.js');
|
|
1523
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1883
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
1524
1884
|
await runContinuationChain({
|
|
1525
1885
|
prepared: planContinue.prepared,
|
|
1526
1886
|
marker: planContinue.marker,
|
|
@@ -1529,12 +1889,13 @@ export async function runRepl(opts) {
|
|
|
1529
1889
|
cwd: process.cwd(),
|
|
1530
1890
|
subagentDispatches: planSubagentDispatches,
|
|
1531
1891
|
prompt: inkAwarePrompt,
|
|
1892
|
+
confirm: inkAwareConfirm,
|
|
1532
1893
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
1533
1894
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1534
1895
|
});
|
|
1535
1896
|
}
|
|
1536
1897
|
catch (err) {
|
|
1537
|
-
chunkEmitter.emit('chunk',
|
|
1898
|
+
chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
|
|
1538
1899
|
}
|
|
1539
1900
|
}
|
|
1540
1901
|
// C1 — the phase reached a resolved terminal (revision persisted, chain
|
|
@@ -1565,6 +1926,7 @@ export async function runRepl(opts) {
|
|
|
1565
1926
|
snapshot: turnSnapshot,
|
|
1566
1927
|
chunkEmitter,
|
|
1567
1928
|
turnModel: planModelOverride ?? session.model,
|
|
1929
|
+
cancelled: turnCancelledByUser,
|
|
1568
1930
|
});
|
|
1569
1931
|
session.last_skill = previousSkill ?? null;
|
|
1570
1932
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
@@ -1582,11 +1944,11 @@ export async function runRepl(opts) {
|
|
|
1582
1944
|
// the transcript reset — it runs for typed AND dispatched resumes,
|
|
1583
1945
|
// and only AFTER preparePlanResume succeeds, so a failed prepare
|
|
1584
1946
|
// never destroys the session context.
|
|
1585
|
-
chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
|
|
1586
1947
|
// Schedule on next tick so the current handleSubmit's promise
|
|
1587
1948
|
// resolves cleanly before the next one starts. Recursion via
|
|
1588
1949
|
// setImmediate keeps the call stack flat across long chains.
|
|
1589
|
-
|
|
1950
|
+
// (autoDispatch skips it when a post-turn Ctrl+C / /exit asked to stop.)
|
|
1951
|
+
autoDispatch(dispatched);
|
|
1590
1952
|
}
|
|
1591
1953
|
};
|
|
1592
1954
|
// BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
|
|
@@ -1605,47 +1967,149 @@ export async function runRepl(opts) {
|
|
|
1605
1967
|
// check here is a backstop for any direct caller that bypasses
|
|
1606
1968
|
// beginSubmit's guard — it queues the line as steering, exactly like
|
|
1607
1969
|
// beginSubmit does mid-turn, instead of starting a twin.
|
|
1970
|
+
/** Round 4 item 9b — the in-flight (or last) dispatch; settled before unmount on exit. */
|
|
1971
|
+
let dispatchTail = null;
|
|
1608
1972
|
const handleSubmit = async (line) => {
|
|
1609
1973
|
if (turnStatusEmitter.isBusy()) {
|
|
1610
1974
|
const t = line.trim();
|
|
1975
|
+
// Fix round 3 (item 5): slash commands are never steering text.
|
|
1976
|
+
const kind = classifyMidTurnLine(t);
|
|
1977
|
+
if (kind.kind === 'exit') {
|
|
1978
|
+
onExitRequest();
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
if (kind.kind === 'slash') {
|
|
1982
|
+
chunkEmitter.emit('chunk', midTurnSlashHint(kind.command));
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1611
1985
|
if (t.length > 0) {
|
|
1612
1986
|
enqueueSteering(t);
|
|
1613
|
-
|
|
1614
|
-
chunkEmitter.emit('chunk', chalk.cyan(`↳ queued ▸ ${echo}${t.length > echo.length ? '…' : ''}\n`));
|
|
1987
|
+
chunkEmitter.emit('chunk', steeringEchoLine(t));
|
|
1615
1988
|
}
|
|
1616
1989
|
return;
|
|
1617
1990
|
}
|
|
1991
|
+
idleKeepalive.disarm(); // Task 15 — a submit ends the idle period
|
|
1618
1992
|
turnStatusEmitter.reserve();
|
|
1993
|
+
postTurn = false;
|
|
1994
|
+
postTurnMayChain = false;
|
|
1619
1995
|
try {
|
|
1620
|
-
|
|
1996
|
+
// Round 4 item 9b — the exit path waits (capped) for this to finish.
|
|
1997
|
+
const run = dispatchSubmit(line);
|
|
1998
|
+
dispatchTail = run;
|
|
1999
|
+
await run;
|
|
1621
2000
|
}
|
|
1622
2001
|
finally {
|
|
1623
2002
|
turnStatusEmitter.release();
|
|
2003
|
+
postTurn = false;
|
|
2004
|
+
postTurnMayChain = false;
|
|
2005
|
+
cancelAnnouncedPhase = null;
|
|
2006
|
+
// A cancel asked for during prep of a dispatch that never started a
|
|
2007
|
+
// turn must not leak into the next one.
|
|
2008
|
+
pendingCancel.clear();
|
|
2009
|
+
interruptCount = 0;
|
|
2010
|
+
// `/exit` typed mid-turn: the turn has now stopped and saved — exit.
|
|
2011
|
+
if (exitAfterTurn) {
|
|
2012
|
+
done = true;
|
|
2013
|
+
if (resolveDone)
|
|
2014
|
+
resolveDone();
|
|
2015
|
+
}
|
|
2016
|
+
// Task 15 — back at the prompt: warm the cache while the user reads
|
|
2017
|
+
// (no-op unless this submit made a new cacheable request). Not when
|
|
2018
|
+
// the submit ended in an exit (merge: UI round-3 /exit mid-turn).
|
|
2019
|
+
if (!done)
|
|
2020
|
+
idleKeepalive.armAfterTurn(session);
|
|
2021
|
+
}
|
|
2022
|
+
};
|
|
2023
|
+
// Fix round 3 (item 5) — `/exit` / `/quit` typed while a turn runs:
|
|
2024
|
+
// cancel the turn the same way Ctrl+C does, then exit once dispatch
|
|
2025
|
+
// returns (handleSubmit's finally). Mid-prep (no abort controller yet)
|
|
2026
|
+
// the cancel is held and applied when the turn creates its controller,
|
|
2027
|
+
// or stops routing outright (review I2).
|
|
2028
|
+
let exitAfterTurn = false;
|
|
2029
|
+
// Final review B-I1 — a /rewind restore is not a turn and runs outside
|
|
2030
|
+
// handleSubmit: interrupts during it are decided as idle, /exit waits for
|
|
2031
|
+
// it, and the cancel/exit state is reset when it ends.
|
|
2032
|
+
const restoreGuard = createRestoreGuard({
|
|
2033
|
+
resetDispatchState: () => {
|
|
2034
|
+
cancelAnnouncedPhase = null;
|
|
2035
|
+
pendingCancel.clear();
|
|
2036
|
+
interruptCount = 0;
|
|
2037
|
+
},
|
|
2038
|
+
exit: () => { done = true; if (resolveDone)
|
|
2039
|
+
resolveDone(); },
|
|
2040
|
+
});
|
|
2041
|
+
const onExitRequest = () => {
|
|
2042
|
+
if (restoreGuard.active) {
|
|
2043
|
+
restoreGuard.requestExit();
|
|
2044
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting when the restore has finished'));
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
if (!turnStatusEmitter.isBusy() && !currentTurnAbort) {
|
|
2048
|
+
done = true;
|
|
2049
|
+
if (resolveDone)
|
|
2050
|
+
resolveDone();
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
exitAfterTurn = true;
|
|
2054
|
+
if (currentTurnAbort && interruptCount === 0) {
|
|
2055
|
+
interruptCount++;
|
|
2056
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · exiting when it has saved'));
|
|
2057
|
+
currentTurnAbort.abort();
|
|
2058
|
+
}
|
|
2059
|
+
else if (!currentTurnAbort) {
|
|
2060
|
+
announcePendingCancel(true);
|
|
1624
2061
|
}
|
|
1625
2062
|
};
|
|
1626
2063
|
// Phase D5 (2026-05-17) — Ctrl+C handler routed through Ink.
|
|
1627
2064
|
// First press during turn: abort the turn (preserves partial work).
|
|
1628
2065
|
// Second press during the SAME turn: hard-exit (escape hatch in case
|
|
1629
2066
|
// the abort doesn't take effect for some reason).
|
|
1630
|
-
// Press at idle:
|
|
2067
|
+
// Press at idle (fix round 3, item 4): the first press only arms the
|
|
2068
|
+
// exit — App shows "Press Ctrl+C again to exit" — and a second press
|
|
2069
|
+
// within IDLE_EXIT_WINDOW_MS exits cleanly (printGoodbye + leaveAltScreen).
|
|
2070
|
+
// Cards / sheets / palettes eat their own Ctrl+C and never get here.
|
|
2071
|
+
let idleInterruptAt = 0;
|
|
1631
2072
|
const onInterrupt = () => {
|
|
1632
|
-
|
|
2073
|
+
const now = Date.now();
|
|
2074
|
+
const action = decideInterrupt({
|
|
2075
|
+
turnActive: currentTurnAbort !== null,
|
|
2076
|
+
// Review I3 — routing / prep in flight without a controller yet is
|
|
2077
|
+
// not idle: cancel the pending turn, never arm "press again".
|
|
2078
|
+
busy: turnStatusEmitter.isBusy() && !restoreGuard.active,
|
|
2079
|
+
phase: cancelPhase(),
|
|
2080
|
+
canChain: canChainNow(),
|
|
2081
|
+
turnPresses: interruptCount,
|
|
2082
|
+
lastIdleAt: idleInterruptAt,
|
|
2083
|
+
now,
|
|
2084
|
+
});
|
|
2085
|
+
if (action === 'abort-turn') {
|
|
1633
2086
|
interruptCount++;
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
2087
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · saving the work done so far'));
|
|
2088
|
+
currentTurnAbort.abort();
|
|
2089
|
+
}
|
|
2090
|
+
else if (action === 'cancel-pending') {
|
|
2091
|
+
interruptCount++;
|
|
2092
|
+
announcePendingCancel(false);
|
|
2093
|
+
}
|
|
2094
|
+
else if (action === 'force-exit') {
|
|
2095
|
+
// Second Ctrl+C during the same turn — give up and exit.
|
|
2096
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting · second Ctrl+C'));
|
|
2097
|
+
unmount();
|
|
2098
|
+
process.stdout.write('\x1b[?25h'); // restore cursor
|
|
2099
|
+
printGoodbye(session);
|
|
2100
|
+
// Final review B M-2 — through gracefulExit (fetch drain, capped).
|
|
2101
|
+
// Re-review item 3 — mark the session done first, so nothing queued
|
|
2102
|
+
// (auto-dispatch, the loop) starts during the drain window.
|
|
2103
|
+
done = true;
|
|
2104
|
+
exitAfterTurn = true;
|
|
2105
|
+
void gracefulExit(130); // 128 + SIGINT
|
|
2106
|
+
}
|
|
2107
|
+
else if (action === 'arm') {
|
|
2108
|
+
idleInterruptAt = now;
|
|
2109
|
+
return 'armed';
|
|
1646
2110
|
}
|
|
1647
2111
|
else {
|
|
1648
|
-
//
|
|
2112
|
+
// Second idle press inside the window — clean exit, same path as /exit.
|
|
1649
2113
|
done = true;
|
|
1650
2114
|
if (resolveDone)
|
|
1651
2115
|
resolveDone();
|
|
@@ -1665,7 +2129,10 @@ export async function runRepl(opts) {
|
|
|
1665
2129
|
enableBracketedPaste();
|
|
1666
2130
|
process.on('exit', () => disableBracketedPaste());
|
|
1667
2131
|
const inkStdin = new InkStdinPasteGuard();
|
|
1668
|
-
|
|
2132
|
+
// Live fix 2026-09-26 — an @inquirer prompt that ran before this point
|
|
2133
|
+
// (resume picker, SAP picker, API key, first run, onboarding) leaves
|
|
2134
|
+
// Windows console stdin dead to the pipe. Cycle raw mode + flow first.
|
|
2135
|
+
pipeRevivedStdin(inkStdin, process.stdin);
|
|
1669
2136
|
// Task 3 (ux-wave3) — rewind wiring, shared with the classic path through
|
|
1670
2137
|
// executeRestore (which holds the single-flight reserve for the FULL
|
|
1671
2138
|
// restore — review I1a: a restore counts as busy, submits become steering —
|
|
@@ -1676,25 +2143,25 @@ export async function runRepl(opts) {
|
|
|
1676
2143
|
const inkRequestApproval = (change, risk, transport) => new Promise((resolve) => {
|
|
1677
2144
|
approvalEmitter.emit('request', { change, effectiveRisk: risk, transport, resolve });
|
|
1678
2145
|
});
|
|
1679
|
-
const runInkRestore = (c) => executeRestore(c, {
|
|
2146
|
+
const runInkRestore = (c) => restoreGuard.run(() => executeRestore(c, {
|
|
1680
2147
|
adt, cfg, alias: selectedAlias,
|
|
1681
|
-
toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook:
|
|
2148
|
+
toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1682
2149
|
suppliedTransport: currentTransportAccessor.get() ?? undefined,
|
|
1683
2150
|
requestApproval: inkRequestApproval,
|
|
1684
2151
|
// deps.log streams each pipeline step dim to scrollback live; the final
|
|
1685
|
-
//
|
|
2152
|
+
// the done / cancelled / failed summary comes from executeRestore's print.
|
|
1686
2153
|
log: (line) => chunkEmitter.emit('chunk', chalk.dim(line) + '\n'),
|
|
1687
|
-
}, (line) => chunkEmitter.emit('chunk', line + '\n'));
|
|
2154
|
+
}, (line) => chunkEmitter.emit('chunk', line + '\n')));
|
|
1688
2155
|
const { unmount } = render(React.createElement(App, {
|
|
1689
2156
|
chunkEmitter,
|
|
1690
2157
|
onSubmit: (line) => { void handleSubmit(line); },
|
|
1691
2158
|
onSlashTrigger: () => { },
|
|
1692
2159
|
onHistoryTrigger: () => { },
|
|
1693
2160
|
getToolCalls: () => session.toolCalls,
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
2161
|
+
// Live-checklist seed accessor (getToolCalls pattern; piece 2 P2):
|
|
2162
|
+
// App reads persisted todos on mount and when the rewind panel
|
|
2163
|
+
// closes, so a resumed session shows its plan before any todo_set
|
|
2164
|
+
// fires in this process.
|
|
1698
2165
|
getTodos: () => session.todos ?? [],
|
|
1699
2166
|
// Task 3 (ux-wave3) — rewind panel accessors. Candidates join
|
|
1700
2167
|
// session.toolCalls with the on-disk snapshot list (async fs read),
|
|
@@ -1711,6 +2178,7 @@ export async function runRepl(opts) {
|
|
|
1711
2178
|
sapRole: cfg.sap[selectedAlias]?.role,
|
|
1712
2179
|
sapAlias: selectedAlias,
|
|
1713
2180
|
onInterrupt,
|
|
2181
|
+
onExitRequest,
|
|
1714
2182
|
}), {
|
|
1715
2183
|
// Bug 2 (wave1 live-fix) — Ink reads from the paste guard, not
|
|
1716
2184
|
// process.stdin directly. The guard satisfies Ink's full stdin
|
|
@@ -1732,7 +2200,13 @@ export async function runRepl(opts) {
|
|
|
1732
2200
|
patchConsole: true,
|
|
1733
2201
|
});
|
|
1734
2202
|
await donePromise;
|
|
2203
|
+
// Round 4 item 9b — a second idle Ctrl+C right after a turn resolved the
|
|
2204
|
+
// exit while post-turn work (saving, audit) still ran. Let it finish,
|
|
2205
|
+
// capped so a stuck step can never hold the exit.
|
|
2206
|
+
const exitTail = await settleWithin(dispatchTail, EXIT_TAIL_CAP_MS);
|
|
1735
2207
|
unmount();
|
|
2208
|
+
setTraceEmitter(undefined);
|
|
2209
|
+
idleKeepalive.disarm(); // Task 15 — no pings after /exit
|
|
1736
2210
|
// Bug 2 (wave1 live-fix) — undo the paste plumbing so the user's
|
|
1737
2211
|
// shell doesn't keep receiving marker-wrapped pastes after exit.
|
|
1738
2212
|
process.stdin.unpipe(inkStdin);
|
|
@@ -1744,6 +2218,11 @@ export async function runRepl(opts) {
|
|
|
1744
2218
|
// line lands in the user's shell scrollback alongside the session
|
|
1745
2219
|
// history. In inline mode the conversation IS in scrollback (no
|
|
1746
2220
|
// alt-screen erased it), so this is just a friendly bookend.
|
|
2221
|
+
// Review M7 — the cap expired: the process exits next, so say the save may
|
|
2222
|
+
// not have landed instead of cutting it off silently.
|
|
2223
|
+
if (exitTail === 'timeout') {
|
|
2224
|
+
console.log(formatAttention({ text: 'Post-turn work did not finish', fact: 'the session file may be incomplete' }).trimEnd());
|
|
2225
|
+
}
|
|
1747
2226
|
printGoodbye(session);
|
|
1748
2227
|
return 0;
|
|
1749
2228
|
}
|
|
@@ -1756,7 +2235,10 @@ export async function runRepl(opts) {
|
|
|
1756
2235
|
// a single line before readline sees it. See src/repl/bracketed-paste.ts.
|
|
1757
2236
|
enableBracketedPaste();
|
|
1758
2237
|
const pasteInput = new BracketedPasteDecoder();
|
|
1759
|
-
|
|
2238
|
+
// Review I2: the classic path is reached through the same pre-REPL inquirer
|
|
2239
|
+
// prompts (resume picker, SAP picker, password, first run) — revive stdin
|
|
2240
|
+
// with the full reset cycle before piping, exactly like the Ink branch.
|
|
2241
|
+
pipeRevivedStdin(pasteInput, process.stdin);
|
|
1760
2242
|
const history = loadHistory();
|
|
1761
2243
|
const rl = readline.createInterface({
|
|
1762
2244
|
input: pasteInput,
|
|
@@ -1790,7 +2272,7 @@ export async function runRepl(opts) {
|
|
|
1790
2272
|
// empty. Attached ONLY for non-TTY stdin: interactive type-ahead during
|
|
1791
2273
|
// a turn keeps its existing semantics.
|
|
1792
2274
|
const pipedStdin = !process.stdin.isTTY;
|
|
1793
|
-
const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl) : noopEarlyLineBuffer();
|
|
2275
|
+
const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl, process.stdin) : noopEarlyLineBuffer();
|
|
1794
2276
|
// B2 fix (2026-06-12) — classic-mode notice channel. Tool handlers emit
|
|
1795
2277
|
// 'warn'/'info' (and pre-formatted 'chunk' lines like the write verify
|
|
1796
2278
|
// chain) on ctx.chunkEmitter; in classic mode that slot used to be
|
|
@@ -1800,8 +2282,9 @@ export async function runRepl(opts) {
|
|
|
1800
2282
|
// keep their direct-stdout classic path (spinner stays enabled).
|
|
1801
2283
|
const classicOutputEmitter = createClassicOutputEmitter();
|
|
1802
2284
|
// Prompt string: 'CSPeach ' + '›' in peach colour
|
|
1803
|
-
|
|
1804
|
-
|
|
2285
|
+
// I1 — the classic prompt is not legacy-pinned; it takes the brand role (spec §1.1).
|
|
2286
|
+
const promptStr = 'CSPeach ' + paint('brand', '›') + ' ';
|
|
2287
|
+
// When we dispatch an inquirer sub-prompt (e.g. the coaching picker),
|
|
1805
2288
|
// readline is paused to give inquirer exclusive stdin access. On resolve
|
|
1806
2289
|
// inquirer sometimes disturbs the stdin stream in a way that makes our
|
|
1807
2290
|
// readline fire 'close' even though the user did NOT press Ctrl-D. Suppress
|
|
@@ -1835,7 +2318,7 @@ export async function runRepl(opts) {
|
|
|
1835
2318
|
// v0.6 — module-scope flag the per-turn SIGINT handler sets to true while
|
|
1836
2319
|
// a turn is in flight. Both `rl.on('close')` and `rl.on('SIGINT')` below
|
|
1837
2320
|
// 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 "
|
|
2321
|
+
// handler that wants to abort the LLM stream and render "· Turn cancelled"
|
|
1839
2322
|
// Without this flag, Ctrl+C during a stream lands in the readline SIGINT
|
|
1840
2323
|
// path first (line buffer is empty during streaming → "Goodbye" + exit),
|
|
1841
2324
|
// killing the CLI before the turn handler can do anything useful.
|
|
@@ -1875,7 +2358,7 @@ export async function runRepl(opts) {
|
|
|
1875
2358
|
// v0.6 — turn-scoped SIGINT handler (registered just before runTurn
|
|
1876
2359
|
// and torn down right after) takes precedence. If a turn is in flight,
|
|
1877
2360
|
// the user wants to interrupt THE TURN, not exit the CLI. Returning
|
|
1878
|
-
// early lets the turn handler run its abort + "
|
|
2361
|
+
// early lets the turn handler run its abort + "· Turn cancelled" UX.
|
|
1879
2362
|
if (turnInProgress)
|
|
1880
2363
|
return;
|
|
1881
2364
|
// Inspect the readline's internal line buffer + our prefill state.
|
|
@@ -2001,9 +2484,11 @@ export async function runRepl(opts) {
|
|
|
2001
2484
|
// can pause us and suppress the spurious 'close' inquirer emits on
|
|
2002
2485
|
// release. See src/repl/inquirer-guard.ts for the failure mode this
|
|
2003
2486
|
// prevents (CSPeach silently exiting after a tool-mediated prompt).
|
|
2487
|
+
// Fix 8 (2026-09-26): pass pasteInput so a prompt detaches it from stdin —
|
|
2488
|
+
// keys typed into the prompt must not be replayed into the REPL line.
|
|
2004
2489
|
registerReadline(rl, (v) => {
|
|
2005
2490
|
suppressRlClose = v;
|
|
2006
|
-
});
|
|
2491
|
+
}, { input: pasteInput });
|
|
2007
2492
|
// 2026-05-08: one-time workspace-default migration. The default workspace
|
|
2008
2493
|
// changed from `~/CSPeach/` to project-rooted `<git-root>/.cspeach/`.
|
|
2009
2494
|
// Existing users with files in ~/CSPeach/ get asked once how to handle
|
|
@@ -2058,7 +2543,7 @@ export async function runRepl(opts) {
|
|
|
2058
2543
|
// transcript reset — it runs for typed AND dispatched resumes, and
|
|
2059
2544
|
// only AFTER preparePlanResume succeeds, so a failed prepare never
|
|
2060
2545
|
// destroys the session context.
|
|
2061
|
-
console.log(
|
|
2546
|
+
console.log(formatInfo(`Auto-routed: ${dispatchedCmd}`).trimEnd());
|
|
2062
2547
|
line = dispatchedCmd;
|
|
2063
2548
|
}
|
|
2064
2549
|
else if (earlyLines.size() > 0) {
|
|
@@ -2076,8 +2561,12 @@ export async function runRepl(opts) {
|
|
|
2076
2561
|
}
|
|
2077
2562
|
else {
|
|
2078
2563
|
const effectivePrompt = pendingPrefill ? promptStr + pendingPrefill : promptStr;
|
|
2564
|
+
// Task 15 — idle at the prompt: warm the cache (no-op unless the last
|
|
2565
|
+
// turn made a new cacheable request); disarmed once the line arrives.
|
|
2566
|
+
idleKeepalive.armAfterTurn(session);
|
|
2079
2567
|
try {
|
|
2080
2568
|
line = await rl.question(effectivePrompt);
|
|
2569
|
+
idleKeepalive.disarm();
|
|
2081
2570
|
}
|
|
2082
2571
|
catch {
|
|
2083
2572
|
// rl.question rejects for two distinct reasons:
|
|
@@ -2091,6 +2580,7 @@ export async function runRepl(opts) {
|
|
|
2091
2580
|
// discriminates the two cases reliably.
|
|
2092
2581
|
const rlAny = rl;
|
|
2093
2582
|
if (rlAny.closed) {
|
|
2583
|
+
idleKeepalive.disarm();
|
|
2094
2584
|
// UX-5 #5 — piped stdin: a multi-line chunk can resolve the
|
|
2095
2585
|
// pending question with line 1 and QUEUE line 2+ before EOF
|
|
2096
2586
|
// closes the readline. Drain the queue before exiting.
|
|
@@ -2114,6 +2604,9 @@ export async function runRepl(opts) {
|
|
|
2114
2604
|
const trimmed = line.trim();
|
|
2115
2605
|
if (!trimmed)
|
|
2116
2606
|
continue;
|
|
2607
|
+
// Round 6 review I2 — see the Ink path.
|
|
2608
|
+
const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
|
|
2609
|
+
resumeKeepsSkill = false;
|
|
2117
2610
|
// Persist non-empty input to history file (Task 8: consecutive
|
|
2118
2611
|
// duplicates are skipped inside the shared writer).
|
|
2119
2612
|
appendHistoryEntry(trimmed);
|
|
@@ -2147,7 +2640,7 @@ export async function runRepl(opts) {
|
|
|
2147
2640
|
continue;
|
|
2148
2641
|
}
|
|
2149
2642
|
if (trimmed.endsWith(' /cancel')) {
|
|
2150
|
-
console.log(
|
|
2643
|
+
console.log(formatRoutingLine({ kind: 'cancelled' }).trimEnd());
|
|
2151
2644
|
continue;
|
|
2152
2645
|
}
|
|
2153
2646
|
if (trimmed === '/exit' || trimmed === '/quit') {
|
|
@@ -2215,7 +2708,7 @@ export async function runRepl(opts) {
|
|
|
2215
2708
|
if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
|
|
2216
2709
|
const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
|
|
2217
2710
|
const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
|
|
2218
|
-
console.log(
|
|
2711
|
+
console.log(formatInfo(modeLine).trimEnd());
|
|
2219
2712
|
sapStateStore.update({ writeMode: mode });
|
|
2220
2713
|
continue;
|
|
2221
2714
|
}
|
|
@@ -2228,13 +2721,9 @@ export async function runRepl(opts) {
|
|
|
2228
2721
|
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
2229
2722
|
const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
|
|
2230
2723
|
console.log('');
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
else {
|
|
2235
|
-
for (const recapLine of recapLines)
|
|
2236
|
-
console.log(chalk.dim(recapLine));
|
|
2237
|
-
}
|
|
2724
|
+
console.log(recapLines.length === 0
|
|
2725
|
+
? formatInfo('Nothing to recap yet').trimEnd()
|
|
2726
|
+
: formatPanel('Recap', recapLines.map((l) => ' ' + paint('dim', sanitizeControlChars(l)))).join('\n'));
|
|
2238
2727
|
continue;
|
|
2239
2728
|
}
|
|
2240
2729
|
// Phase 2 conversation hygiene (2026-05-16) — classic-mode /compact.
|
|
@@ -2249,15 +2738,15 @@ export async function runRepl(opts) {
|
|
|
2249
2738
|
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
|
|
2250
2739
|
// model-governance step 2d — compact model resolves env > local >
|
|
2251
2740
|
// server > built-in (== COMPACTION_MODEL when nothing set).
|
|
2252
|
-
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
2741
|
+
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
|
|
2253
2742
|
};
|
|
2254
2743
|
try {
|
|
2255
|
-
console.log(
|
|
2744
|
+
console.log('\n' + formatInfo('Compacting older turns into a summary…').trimEnd());
|
|
2256
2745
|
const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
|
|
2257
2746
|
console.log(formatCompactResult(result, compactCfg.keep_recent_turns));
|
|
2258
2747
|
}
|
|
2259
2748
|
catch (err) {
|
|
2260
|
-
console.log(
|
|
2749
|
+
console.log('\n' + formatFailure({ what: 'Compact failed', detail: [err instanceof Error ? err.message : String(err)] }));
|
|
2261
2750
|
}
|
|
2262
2751
|
continue;
|
|
2263
2752
|
}
|
|
@@ -2278,7 +2767,7 @@ export async function runRepl(opts) {
|
|
|
2278
2767
|
const { openFilePicker } = await import('./repl/file-picker.js');
|
|
2279
2768
|
const result = await openFilePicker();
|
|
2280
2769
|
if (result.kind === 'command') {
|
|
2281
|
-
console.log(
|
|
2770
|
+
console.log(formatInfo('Picked · press Enter to run, or edit first').trimEnd());
|
|
2282
2771
|
pendingPrefill = result.command;
|
|
2283
2772
|
}
|
|
2284
2773
|
else if (result.kind === 'empty') {
|
|
@@ -2293,7 +2782,7 @@ export async function runRepl(opts) {
|
|
|
2293
2782
|
// result.kind === 'cancelled' — print nothing.
|
|
2294
2783
|
}
|
|
2295
2784
|
catch (err) {
|
|
2296
|
-
console.log(
|
|
2785
|
+
console.log('\n' + formatFailure({ what: 'Could not open the file list', detail: [err.message] }));
|
|
2297
2786
|
}
|
|
2298
2787
|
continue;
|
|
2299
2788
|
}
|
|
@@ -2302,31 +2791,11 @@ export async function runRepl(opts) {
|
|
|
2302
2791
|
// Defensive: a config that predates the [ui] section may have cfg.ui === undefined
|
|
2303
2792
|
// if loaded before loader.ts's deep-merge fix. Initialise to the default.
|
|
2304
2793
|
if (!cfg.ui)
|
|
2305
|
-
cfg.ui = { rendering: 'auto' };
|
|
2794
|
+
cfg.ui = { rendering: 'auto', look: DEFAULT_CONFIG.ui.look };
|
|
2306
2795
|
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
|
-
}
|
|
2796
|
+
if (await handleUiLook(arg, cfg, saveConfig, (s) => console.log(s)))
|
|
2797
|
+
continue;
|
|
2798
|
+
await handleUiRendering(arg, cfg, saveConfig, (s) => console.log(s), shouldUseInk() ? 'ink' : 'classic');
|
|
2330
2799
|
continue;
|
|
2331
2800
|
}
|
|
2332
2801
|
// /reroute — re-dispatch session.lastUserPrompt to a different skill
|
|
@@ -2338,15 +2807,26 @@ export async function runRepl(opts) {
|
|
|
2338
2807
|
continue;
|
|
2339
2808
|
}
|
|
2340
2809
|
const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
|
|
2341
|
-
console.log(
|
|
2810
|
+
console.log(formatInfo(`Rerouting "${preview}" to /${rr.skill}`).trimEnd());
|
|
2342
2811
|
if (rr.warning)
|
|
2343
2812
|
console.log(chalk.dim(` (${rr.warning})`));
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2813
|
+
// A turn error prints the same stderr block as the normal turn path.
|
|
2814
|
+
await runRerouteTurn({
|
|
2815
|
+
run: () => runTurn({
|
|
2816
|
+
provider,
|
|
2817
|
+
userMessage: rr.prompt,
|
|
2818
|
+
skill: rr.skill,
|
|
2819
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
2820
|
+
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
2821
|
+
autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
|
|
2822
|
+
}),
|
|
2823
|
+
session,
|
|
2824
|
+
print: (line) => console.error(line),
|
|
2825
|
+
onFinally: () => {
|
|
2826
|
+
// P2 fix round 1 — /reroute skips pushPostTurnStatus, so run its
|
|
2827
|
+
// turn-end plan step here (classic prints the block once).
|
|
2828
|
+
emitPlanTurnEnd(session);
|
|
2829
|
+
},
|
|
2350
2830
|
});
|
|
2351
2831
|
continue;
|
|
2352
2832
|
}
|
|
@@ -2354,7 +2834,8 @@ export async function runRepl(opts) {
|
|
|
2354
2834
|
// classified afresh instead of continuing the previous skill's Q&A chain.
|
|
2355
2835
|
if (trimmed === '/new' || trimmed === '/reset') {
|
|
2356
2836
|
session.awaitingSkillAnswer = false;
|
|
2357
|
-
|
|
2837
|
+
session.todos = []; // P2 — /new clears the plan (spec §4)
|
|
2838
|
+
console.log(formatInfo('Fresh start · the next prompt picks a skill again').trimEnd());
|
|
2358
2839
|
continue;
|
|
2359
2840
|
}
|
|
2360
2841
|
// /transport — session current-transport state for Rule 9 (transport isolation)
|
|
@@ -2409,7 +2890,7 @@ export async function runRepl(opts) {
|
|
|
2409
2890
|
// the prefilled buffer so iter 3 starts fresh, clear the
|
|
2410
2891
|
// guard, and skip dispatch.
|
|
2411
2892
|
lastCancelledSlashLine = null;
|
|
2412
|
-
console.log(
|
|
2893
|
+
console.log(formatInfo('Dropped').trimEnd());
|
|
2413
2894
|
process.nextTick(() => prefillReadline(rl, ''));
|
|
2414
2895
|
continue;
|
|
2415
2896
|
}
|
|
@@ -2421,7 +2902,7 @@ export async function runRepl(opts) {
|
|
|
2421
2902
|
const prefix = head.slice(1); // drop the leading /
|
|
2422
2903
|
const picked = await openSlashPicker(prefix);
|
|
2423
2904
|
if (picked === null) {
|
|
2424
|
-
console.log(
|
|
2905
|
+
console.log(formatInfo('Cancelled · edit, or press Enter again to drop').trimEnd());
|
|
2425
2906
|
lastCancelledSlashLine = trimmed;
|
|
2426
2907
|
process.nextTick(() => prefillReadline(rl, trimmed));
|
|
2427
2908
|
continue;
|
|
@@ -2448,13 +2929,13 @@ export async function runRepl(opts) {
|
|
|
2448
2929
|
// Without this the line went to the LLM with a bare/partial
|
|
2449
2930
|
// @ — wasted a turn and confused the user who'd just cancelled.
|
|
2450
2931
|
lastCancelledAtLine = null;
|
|
2451
|
-
console.log(
|
|
2932
|
+
console.log(formatInfo('Dropped').trimEnd());
|
|
2452
2933
|
process.nextTick(() => prefillReadline(rl, ''));
|
|
2453
2934
|
continue;
|
|
2454
2935
|
}
|
|
2455
2936
|
const pickedFile = await openAtPicker(atPrefix);
|
|
2456
2937
|
if (pickedFile === null) {
|
|
2457
|
-
console.log(
|
|
2938
|
+
console.log(formatInfo('Cancelled · edit, or press Enter again to drop').trimEnd());
|
|
2458
2939
|
lastCancelledAtLine = resolvedTrimmed;
|
|
2459
2940
|
process.nextTick(() => prefillReadline(rl, resolvedTrimmed));
|
|
2460
2941
|
continue;
|
|
@@ -2499,9 +2980,7 @@ export async function runRepl(opts) {
|
|
|
2499
2980
|
// then asked an unanswerable question. Every skill now carries an
|
|
2500
2981
|
// `asks` line with an example (skill-catalog.ts).
|
|
2501
2982
|
const asks = SKILL_CATALOG.find((e) => `/${e.name}` === head)?.asks;
|
|
2502
|
-
console.log(
|
|
2503
|
-
? `↪ ${head} — ${asks}`
|
|
2504
|
-
: `${'↪'} ${head} — type your prompt and press Enter`));
|
|
2983
|
+
console.log(formatInfo(asks ? `${head} — ${asks}` : `${head} — type your prompt and press Enter`).trimEnd());
|
|
2505
2984
|
console.log(chalk.dim(' Enter to send · /cancel to abandon'));
|
|
2506
2985
|
lastEmptyPrefill = head;
|
|
2507
2986
|
}
|
|
@@ -2567,15 +3046,16 @@ export async function runRepl(opts) {
|
|
|
2567
3046
|
chalk.dim('Type a new question, /new to reset, or /exit to quit.'));
|
|
2568
3047
|
continue;
|
|
2569
3048
|
}
|
|
2570
|
-
|
|
2571
|
-
|
|
3049
|
+
// Round 6 item 4c / review I2 — see the Ink path.
|
|
3050
|
+
if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
|
|
3051
|
+
console.log(formatRoutingLine({ kind: 'continuing', skill: session.skill }).trimEnd());
|
|
2572
3052
|
skill = session.skill;
|
|
2573
3053
|
}
|
|
2574
3054
|
else if (isBareAck(trimmed)) {
|
|
2575
3055
|
// Bare "y" / "n" / "cancel" with no skill to continue — almost always
|
|
2576
3056
|
// a stray keystroke after an aborted turn, not a request to start
|
|
2577
3057
|
// work. Don't burn a classifier round-trip on it.
|
|
2578
|
-
console.log(
|
|
3058
|
+
console.log(formatInfo('No active skill · type /help or a full prompt').trimEnd());
|
|
2579
3059
|
continue;
|
|
2580
3060
|
}
|
|
2581
3061
|
else {
|
|
@@ -2585,46 +3065,76 @@ export async function runRepl(opts) {
|
|
|
2585
3065
|
last_skill: session.last_skill ?? null,
|
|
2586
3066
|
last_object: extractedObject ?? session.last_object ?? null,
|
|
2587
3067
|
});
|
|
2588
|
-
if (c.offline) {
|
|
2589
|
-
console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
|
|
2590
|
-
}
|
|
2591
3068
|
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;
|
|
3069
|
+
console.log(formatInfo(`Screenshot: ${c.screenshot}`).trimEnd());
|
|
3070
|
+
// Fix 10 (2026-09-26): routing failed mid-skill → stay in the
|
|
3071
|
+
// active skill (one dim line), never the picker. Same decision as
|
|
3072
|
+
// the Ink path (routingFailureOutcome).
|
|
3073
|
+
const failed = routingFailureOutcome(routingFailureFromResult(c), session.skill);
|
|
3074
|
+
if (failed.kind === 'stay') {
|
|
3075
|
+
console.log(formatInfo(failed.line).trimEnd());
|
|
3076
|
+
skill = failed.skill;
|
|
2614
3077
|
}
|
|
2615
3078
|
else {
|
|
2616
|
-
|
|
2617
|
-
|
|
3079
|
+
if (c.offline) {
|
|
3080
|
+
// Fix 9: true wording from the failure kind (a timeout is not "offline").
|
|
3081
|
+
console.log(formatAttention(routingFailureNotice(routingFailureFromResult(c), c.transport_error)).trimEnd());
|
|
3082
|
+
}
|
|
3083
|
+
// Round 6 review I2 — see the Ink path.
|
|
3084
|
+
const decision = resumedSkill ? applyResumedSkill(decideRouting(c), resumedSkill) : decideRouting(c);
|
|
3085
|
+
// 2026-05-17 — pass the just-used skill so the picker can
|
|
3086
|
+
// default cursor to it. session.skill = immediately-previous
|
|
3087
|
+
// turn's skill, NOT session.last_skill (TWO turns ago).
|
|
3088
|
+
const continueSkillClassic = session.skill ?? null;
|
|
3089
|
+
let pickedResult;
|
|
3090
|
+
// Coaching picker path — inquirer takes over stdin. Use the shared
|
|
3091
|
+
// withInquirer guard so the suppress-close window stays active
|
|
3092
|
+
// across the close-fire tick (inline guard cleared the flag
|
|
3093
|
+
// synchronously, leaking the close-fire and dropping CSPeach to
|
|
3094
|
+
// the shell mid-session).
|
|
3095
|
+
const runPicker = async (params) => {
|
|
3096
|
+
return withInquirer(() => coachingPickerClassic(params));
|
|
3097
|
+
};
|
|
3098
|
+
if (pipedStdin) {
|
|
3099
|
+
// Round 7 item 8a — piped stdin has no keyboard to answer the
|
|
3100
|
+
// picker (it ended in "readline was closed"): run the router's
|
|
3101
|
+
// top pick, like one-shot.
|
|
3102
|
+
const p = pipedRoutingPick(c);
|
|
3103
|
+
for (const l of p.lines)
|
|
3104
|
+
console.log(l);
|
|
3105
|
+
pickedResult = p.skill;
|
|
3106
|
+
}
|
|
3107
|
+
else if (cfg.classifier.safe_mode) {
|
|
3108
|
+
// Safe mode overrides the decision — always open the full picker.
|
|
3109
|
+
pickedResult = await runPicker(buildCoachingParams(c, trimmed, false, continueSkillClassic));
|
|
3110
|
+
}
|
|
3111
|
+
else if (decision.kind === 'silent') {
|
|
3112
|
+
console.log(formatRoutingLine({ kind: 'using', skill: decision.skill }).trimEnd());
|
|
3113
|
+
pickedResult = decision.skill;
|
|
3114
|
+
}
|
|
3115
|
+
else {
|
|
3116
|
+
const preSelect = decision.kind === 'coach';
|
|
3117
|
+
pickedResult = await runPicker(buildCoachingParams(decision.result, trimmed, preSelect, continueSkillClassic));
|
|
3118
|
+
}
|
|
3119
|
+
if (pickedResult === PICK_CANCELLED) {
|
|
3120
|
+
console.log(formatRoutingLine({ kind: 'cancelled' }).trimEnd());
|
|
3121
|
+
continue;
|
|
3122
|
+
}
|
|
3123
|
+
skill = pickedResult;
|
|
3124
|
+
} // end fix-10 fallback branch
|
|
3125
|
+
}
|
|
3126
|
+
catch (err) {
|
|
3127
|
+
const failure = routingFailureFromError(err);
|
|
3128
|
+
const failed = routingFailureOutcome(failure, session.skill);
|
|
3129
|
+
if (failed.kind === 'stay') {
|
|
3130
|
+
// Fix 10: HTTP routing failure mid-skill → stay in the active skill.
|
|
3131
|
+
console.log(formatInfo(failed.line).trimEnd());
|
|
3132
|
+
skill = failed.skill;
|
|
2618
3133
|
}
|
|
2619
|
-
|
|
2620
|
-
console.log(
|
|
3134
|
+
else {
|
|
3135
|
+
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
3136
|
continue;
|
|
2622
3137
|
}
|
|
2623
|
-
skill = pickedResult;
|
|
2624
|
-
}
|
|
2625
|
-
catch (err) {
|
|
2626
|
-
console.log(chalk.red(`\n[classifier error] ${err.message}`));
|
|
2627
|
-
continue;
|
|
2628
3138
|
}
|
|
2629
3139
|
}
|
|
2630
3140
|
}
|
|
@@ -2652,7 +3162,7 @@ export async function runRepl(opts) {
|
|
|
2652
3162
|
});
|
|
2653
3163
|
}
|
|
2654
3164
|
catch (err) {
|
|
2655
|
-
console.log(
|
|
3165
|
+
console.log('\n' + formatFailure({ what: 'Could not show the status', detail: [err.message] }));
|
|
2656
3166
|
}
|
|
2657
3167
|
session.last_skill = previousSkill ?? null;
|
|
2658
3168
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
@@ -2677,7 +3187,7 @@ export async function runRepl(opts) {
|
|
|
2677
3187
|
if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
|
|
2678
3188
|
try {
|
|
2679
3189
|
const { preparePlanResume } = await import('./commands/plan-resume.js');
|
|
2680
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
3190
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
2681
3191
|
planResume = await preparePlanResume({
|
|
2682
3192
|
body: prompt,
|
|
2683
3193
|
cwd: process.cwd(),
|
|
@@ -2688,11 +3198,12 @@ export async function runRepl(opts) {
|
|
|
2688
3198
|
// Task 9 — run-lease identity + takeover prompt (classic mirror).
|
|
2689
3199
|
sessionId: session.id,
|
|
2690
3200
|
prompt: inkAwarePrompt,
|
|
3201
|
+
confirm: inkAwareConfirm,
|
|
2691
3202
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2692
3203
|
});
|
|
2693
3204
|
}
|
|
2694
3205
|
catch (err) {
|
|
2695
|
-
console.log(
|
|
3206
|
+
console.log('\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
|
|
2696
3207
|
}
|
|
2697
3208
|
if (!planResume) {
|
|
2698
3209
|
session.last_skill = previousSkill ?? null;
|
|
@@ -2706,7 +3217,7 @@ export async function runRepl(opts) {
|
|
|
2706
3217
|
if (planResume.reauditPhaseId) {
|
|
2707
3218
|
try {
|
|
2708
3219
|
const { runReauditPass } = await import('./commands/plan-chain.js');
|
|
2709
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
3220
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
2710
3221
|
await runReauditPass({
|
|
2711
3222
|
savedPath: planResume.path,
|
|
2712
3223
|
phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
|
|
@@ -2718,12 +3229,13 @@ export async function runRepl(opts) {
|
|
|
2718
3229
|
auditMode: planResume.auditMode,
|
|
2719
3230
|
auditFlag: planResume.auditFlag,
|
|
2720
3231
|
prompt: inkAwarePrompt,
|
|
3232
|
+
confirm: inkAwareConfirm,
|
|
2721
3233
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
2722
3234
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2723
3235
|
});
|
|
2724
3236
|
}
|
|
2725
3237
|
catch (err) {
|
|
2726
|
-
console.log(
|
|
3238
|
+
console.log('\n' + formatFailure({ what: 'Plan re-audit failed', detail: [err.message] }));
|
|
2727
3239
|
}
|
|
2728
3240
|
// Task 9 (review fix) — release ONLY when the re-audit leg queued no
|
|
2729
3241
|
// follow-up (classic mirror; the loop-top pre-prompt consumer picks
|
|
@@ -2742,7 +3254,7 @@ export async function runRepl(opts) {
|
|
|
2742
3254
|
{
|
|
2743
3255
|
const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
|
|
2744
3256
|
if (resetContextForPlanResume(session)) {
|
|
2745
|
-
console.log(
|
|
3257
|
+
console.log(formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared').trimEnd());
|
|
2746
3258
|
}
|
|
2747
3259
|
}
|
|
2748
3260
|
planResumeMsgStart = session.messages.length;
|
|
@@ -2788,7 +3300,7 @@ export async function runRepl(opts) {
|
|
|
2788
3300
|
planContinue = { prepared: r.prepared, marker };
|
|
2789
3301
|
planPhaseStartedAt = marker.attemptStartedAt;
|
|
2790
3302
|
planResumeMsgStart = session.messages.length;
|
|
2791
|
-
console.log(
|
|
3303
|
+
console.log(formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`).trimEnd());
|
|
2792
3304
|
}
|
|
2793
3305
|
else if (r.stale) {
|
|
2794
3306
|
session.plan_in_flight = null; // plan/phase gone for good — marker is noise
|
|
@@ -2805,6 +3317,8 @@ export async function runRepl(opts) {
|
|
|
2805
3317
|
let planSubagentDispatches;
|
|
2806
3318
|
const planResumeForHook = planResume;
|
|
2807
3319
|
const turnStart = Date.now();
|
|
3320
|
+
// UI fix round (1.1.20) — card waits are excluded from the receipt's time.
|
|
3321
|
+
const turnStartPausedMs = turnStatusEmitter.pausedMs();
|
|
2808
3322
|
// H1 — `session.usage` is session-LIFETIME (initialised once in
|
|
2809
3323
|
// newSession, saved across turns). The footer's "this turn" label was
|
|
2810
3324
|
// misleading — it actually showed cumulative lifetime tokens. Snapshot
|
|
@@ -2843,7 +3357,7 @@ export async function runRepl(opts) {
|
|
|
2843
3357
|
return;
|
|
2844
3358
|
turnSigintFired = true;
|
|
2845
3359
|
interruptedByUser = true;
|
|
2846
|
-
process.stderr.write(
|
|
3360
|
+
process.stderr.write('\n' + formatInfo('Stopping the turn · saving the work done so far'));
|
|
2847
3361
|
turnAbort.abort();
|
|
2848
3362
|
// Remove ourselves so a SECOND Ctrl+C falls through to the
|
|
2849
3363
|
// existing rl SIGINT handler (which clears the line / exits) once
|
|
@@ -2871,7 +3385,7 @@ export async function runRepl(opts) {
|
|
|
2871
3385
|
if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
|
|
2872
3386
|
setGuardedRunActive(true);
|
|
2873
3387
|
guardedRunFlagSet = true;
|
|
2874
|
-
console.log(
|
|
3388
|
+
console.log(formatInfo('Guarded mode · auto-approve is off for this run').trimEnd());
|
|
2875
3389
|
}
|
|
2876
3390
|
// Off-switch (2026-07-06, owner) — announce plainly that this run's
|
|
2877
3391
|
// phases are NOT audited (honesty: off is a choice, never a lie).
|
|
@@ -2887,8 +3401,13 @@ export async function runRepl(opts) {
|
|
|
2887
3401
|
pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
|
|
2888
3402
|
signal: turnAbort.signal,
|
|
2889
3403
|
suppressSaveHook: planResume !== null || planContinue !== null,
|
|
3404
|
+
// Task 19 — auto-compact at turn end, except plan resume/continue
|
|
3405
|
+
// turns (their post-turn persist indexes messages from turn start).
|
|
3406
|
+
autoCompact: planResume === null && planContinue === null,
|
|
2890
3407
|
// A4 — undefined on every non-plan turn (and when tiering is off).
|
|
2891
3408
|
modelOverride: planModelOverride,
|
|
3409
|
+
// Task 9 — a tier-down, never the session role (proxy must not enforce it).
|
|
3410
|
+
modelRole: planModelOverride ? 'audit' : undefined,
|
|
2892
3411
|
onPlanManifest: planResumeForHook
|
|
2893
3412
|
? async (assistantText) => {
|
|
2894
3413
|
try {
|
|
@@ -2906,7 +3425,7 @@ export async function runRepl(opts) {
|
|
|
2906
3425
|
planSavedEarly = true;
|
|
2907
3426
|
}
|
|
2908
3427
|
catch (err) {
|
|
2909
|
-
console.log(
|
|
3428
|
+
console.log('\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
|
|
2910
3429
|
}
|
|
2911
3430
|
}
|
|
2912
3431
|
: planContinueForHook
|
|
@@ -2933,7 +3452,7 @@ export async function runRepl(opts) {
|
|
|
2933
3452
|
// owns the foreign save / quiet-none handling.
|
|
2934
3453
|
}
|
|
2935
3454
|
catch (err) {
|
|
2936
|
-
console.log(
|
|
3455
|
+
console.log('\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
|
|
2937
3456
|
}
|
|
2938
3457
|
}
|
|
2939
3458
|
: undefined,
|
|
@@ -2990,7 +3509,7 @@ export async function runRepl(opts) {
|
|
|
2990
3509
|
});
|
|
2991
3510
|
}
|
|
2992
3511
|
catch (err) {
|
|
2993
|
-
console.log(
|
|
3512
|
+
console.log('\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
|
|
2994
3513
|
}
|
|
2995
3514
|
}
|
|
2996
3515
|
// A1 (defect D23) + Task 6 — harness-owned continuation, now the full
|
|
@@ -3001,7 +3520,7 @@ export async function runRepl(opts) {
|
|
|
3001
3520
|
if (planResume && planOutcome) {
|
|
3002
3521
|
try {
|
|
3003
3522
|
const { runPostPhaseChain } = await import('./commands/plan-chain.js');
|
|
3004
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
3523
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
3005
3524
|
await runPostPhaseChain({
|
|
3006
3525
|
savedPath: planOutcome.savedPath,
|
|
3007
3526
|
executedPhaseId: planResume.nextPhaseId,
|
|
@@ -3018,12 +3537,13 @@ export async function runRepl(opts) {
|
|
|
3018
3537
|
// Fix-wave (item 1) — counter consumed in the runTurn finally.
|
|
3019
3538
|
subagentDispatches: planSubagentDispatches,
|
|
3020
3539
|
prompt: inkAwarePrompt,
|
|
3540
|
+
confirm: inkAwareConfirm,
|
|
3021
3541
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
3022
3542
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
3023
3543
|
});
|
|
3024
3544
|
}
|
|
3025
3545
|
catch (err) {
|
|
3026
|
-
console.log(
|
|
3546
|
+
console.log('\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
|
|
3027
3547
|
}
|
|
3028
3548
|
}
|
|
3029
3549
|
// C1 — continuation post-turn: fallback-persist (hook may not have
|
|
@@ -3062,13 +3582,13 @@ export async function runRepl(opts) {
|
|
|
3062
3582
|
}
|
|
3063
3583
|
}
|
|
3064
3584
|
catch (err) {
|
|
3065
|
-
console.log(
|
|
3585
|
+
console.log('\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
|
|
3066
3586
|
}
|
|
3067
3587
|
}
|
|
3068
3588
|
if (planContinue && planOutcome) {
|
|
3069
3589
|
try {
|
|
3070
3590
|
const { runContinuationChain } = await import('./commands/plan-continue.js');
|
|
3071
|
-
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
3591
|
+
const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
|
|
3072
3592
|
await runContinuationChain({
|
|
3073
3593
|
prepared: planContinue.prepared,
|
|
3074
3594
|
marker: planContinue.marker,
|
|
@@ -3077,12 +3597,13 @@ export async function runRepl(opts) {
|
|
|
3077
3597
|
cwd: process.cwd(),
|
|
3078
3598
|
subagentDispatches: planSubagentDispatches,
|
|
3079
3599
|
prompt: inkAwarePrompt,
|
|
3600
|
+
confirm: inkAwareConfirm,
|
|
3080
3601
|
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
3081
3602
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
3082
3603
|
});
|
|
3083
3604
|
}
|
|
3084
3605
|
catch (err) {
|
|
3085
|
-
console.log(
|
|
3606
|
+
console.log('\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
|
|
3086
3607
|
}
|
|
3087
3608
|
}
|
|
3088
3609
|
// C1 — the phase reached a resolved terminal (revision persisted, chain
|
|
@@ -3118,10 +3639,13 @@ export async function runRepl(opts) {
|
|
|
3118
3639
|
cacheRead: turnStartCostSnapshot.cacheRead,
|
|
3119
3640
|
cacheCreate: turnStartCostSnapshot.cacheCreate,
|
|
3120
3641
|
startedAt: turnStart,
|
|
3642
|
+
pausedMs: turnStartPausedMs,
|
|
3121
3643
|
},
|
|
3122
3644
|
// A4 — price the turn at the model that actually ran it (classic-path
|
|
3123
3645
|
// mirror of the Ink call site above).
|
|
3124
3646
|
turnModel: planModelOverride ?? session.model,
|
|
3647
|
+
// Review M4 — a user cancel with no tokens prints no receipt here too.
|
|
3648
|
+
cancelled: interruptedByUser,
|
|
3125
3649
|
});
|
|
3126
3650
|
// Post-turn roll-forward (Step 7c.2 / 7c.5) — the skill from the turn that
|
|
3127
3651
|
// JUST finished becomes `last_skill` for the next turn's classifier context.
|