@gaunt-sloth/agent 2.0.0-alpha.3 → 2.0.0-alpha.31
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 +73 -23
- package/dist/builtInToolsConfig.d.ts +17 -3
- package/dist/builtInToolsConfig.js +40 -41
- package/dist/builtInToolsConfig.js.map +1 -1
- package/dist/core/GthDeepAgent.d.ts +52 -32
- package/dist/core/GthDeepAgent.js +438 -198
- package/dist/core/GthDeepAgent.js.map +1 -1
- package/dist/core/debugCapture.d.ts +8 -49
- package/dist/core/debugCapture.js +1 -1
- package/dist/core/debugCapture.js.map +1 -1
- package/dist/core/deepAgentPermissions.d.ts +67 -2
- package/dist/core/deepAgentPermissions.js +151 -3
- package/dist/core/deepAgentPermissions.js.map +1 -1
- package/dist/core/gthAcpServer.js +11 -0
- package/dist/core/gthAcpServer.js.map +1 -1
- package/dist/core/gthDeepAgentFactory.d.ts +3 -0
- package/dist/core/gthDeepAgentFactory.js +9 -1
- package/dist/core/gthDeepAgentFactory.js.map +1 -1
- package/dist/core/resolveAgentFactory.d.ts +14 -0
- package/dist/core/resolveAgentFactory.js +18 -0
- package/dist/core/resolveAgentFactory.js.map +1 -0
- package/dist/core/subagentProfiles.d.ts +50 -0
- package/dist/core/subagentProfiles.js +76 -0
- package/dist/core/subagentProfiles.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/OAuthClientProviderImpl.js.map +1 -1
- package/dist/mcp/tlsTrust.d.ts +61 -0
- package/dist/mcp/tlsTrust.js +109 -0
- package/dist/mcp/tlsTrust.js.map +1 -0
- package/dist/middleware/binaryContentInjectionMiddleware.d.ts +8 -1
- package/dist/middleware/binaryContentInjectionMiddleware.js +11 -2
- package/dist/middleware/binaryContentInjectionMiddleware.js.map +1 -1
- package/dist/middleware/frontendImageInjectionMiddleware.d.ts +106 -0
- package/dist/middleware/frontendImageInjectionMiddleware.js +166 -0
- package/dist/middleware/frontendImageInjectionMiddleware.js.map +1 -0
- package/dist/middleware/registry.js +36 -1
- package/dist/middleware/registry.js.map +1 -1
- package/dist/middleware/types.d.ts +16 -2
- package/dist/modules/a2a/A2AClientWrapper.d.ts +56 -1
- package/dist/modules/a2a/A2AClientWrapper.js +131 -31
- package/dist/modules/a2a/A2AClientWrapper.js.map +1 -1
- package/dist/modules/acpModule.js +17 -1
- package/dist/modules/acpModule.js.map +1 -1
- package/dist/modules/apiAgUiModule.d.ts +87 -0
- package/dist/modules/apiAgUiModule.js +229 -28
- package/dist/modules/apiAgUiModule.js.map +1 -1
- package/dist/modules/interactiveSessionModule.js +501 -41
- package/dist/modules/interactiveSessionModule.js.map +1 -1
- package/dist/modules/slashCommands.d.ts +576 -0
- package/dist/modules/slashCommands.js +995 -0
- package/dist/modules/slashCommands.js.map +1 -0
- package/dist/resolvers.js +119 -9
- package/dist/resolvers.js.map +1 -1
- package/dist/tools/GthCustomToolkit.js +95 -11
- package/dist/tools/GthCustomToolkit.js.map +1 -1
- package/dist/tools/GthDevToolkit.d.ts +11 -3
- package/dist/tools/GthDevToolkit.js +120 -34
- package/dist/tools/GthDevToolkit.js.map +1 -1
- package/dist/tools/GthFileSystemToolkit.d.ts +63 -0
- package/dist/tools/GthFileSystemToolkit.js +506 -105
- package/dist/tools/GthFileSystemToolkit.js.map +1 -1
- package/dist/tools/McpResourceTool.d.ts +31 -0
- package/dist/tools/McpResourceTool.js +106 -0
- package/dist/tools/McpResourceTool.js.map +1 -0
- package/dist/tools/gthChecklistTool.d.ts +30 -0
- package/dist/tools/gthChecklistTool.js +81 -0
- package/dist/tools/gthChecklistTool.js.map +1 -0
- package/dist/tools/gthGrepTool.d.ts +54 -0
- package/dist/tools/gthGrepTool.js +482 -0
- package/dist/tools/gthGrepTool.js.map +1 -0
- package/dist/tools/shell/env.js +1 -1
- package/dist/tools/shell/env.js.map +1 -1
- package/dist/tools/shell/outputBuffer.js.map +1 -1
- package/dist/tools/shell/workDir.d.ts +11 -0
- package/dist/tools/shell/workDir.js +45 -0
- package/dist/tools/shell/workDir.js.map +1 -0
- package/dist/utils/mcpAuthError.d.ts +36 -0
- package/dist/utils/mcpAuthError.js +89 -0
- package/dist/utils/mcpAuthError.js.map +1 -0
- package/dist/utils/mcpUtils.js +16 -0
- package/dist/utils/mcpUtils.js.map +1 -1
- package/package.json +12 -11
- package/dist/tools/shell/allowlist.d.ts +0 -11
- package/dist/tools/shell/allowlist.js +0 -12
- package/dist/tools/shell/allowlist.js.map +0 -1
- package/dist/tools/shell/arity.d.ts +0 -11
- package/dist/tools/shell/arity.js +0 -12
- package/dist/tools/shell/arity.js.map +0 -1
- package/dist/tools/shell/hardline.d.ts +0 -15
- package/dist/tools/shell/hardline.js +0 -88
- package/dist/tools/shell/hardline.js.map +0 -1
- package/dist/tools/shell/normalize.d.ts +0 -10
- package/dist/tools/shell/normalize.js +0 -11
- package/dist/tools/shell/normalize.js.map +0 -1
|
@@ -1,33 +1,111 @@
|
|
|
1
1
|
import { initConfig } from '@gaunt-sloth/core/config.js';
|
|
2
|
-
import { defaultStatusCallback, display, displayInfo, displayWarning, flushSessionLog, formatInputPrompt, initSessionLogging, stopSessionLogging, } from '@gaunt-sloth/core/utils/consoleUtils.js';
|
|
2
|
+
import { defaultStatusCallback, display, displayError, displayInfo, displayLaunchBanner, displayWarning, flushSessionLog, formatInputPrompt, initSessionLogging, stopSessionLogging, } from '@gaunt-sloth/core/utils/consoleUtils.js';
|
|
3
3
|
import { GthAgentRunner } from '@gaunt-sloth/core/core/GthAgentRunner.js';
|
|
4
|
+
import { GthAbstractAgent } from '@gaunt-sloth/core/core/GthAbstractAgent.js';
|
|
5
|
+
import { launchBannerFields, launchBannerText } from '@gaunt-sloth/core/core/launchBanner.js';
|
|
6
|
+
import { buildRejectionMessage } from '@gaunt-sloth/core/core/shell/rejection.js';
|
|
7
|
+
import { renderNegotiationRows } from '@gaunt-sloth/core/core/shell/negotiation.js';
|
|
8
|
+
import { attackBannerCopy, describeRaterOutcome, grantsRunAnyway, RATER_REASON_LABEL, } from '@gaunt-sloth/core/core/shell/escalationSeverity.js';
|
|
9
|
+
import { frameUntrustedCommand, frameUntrustedText, frameWidthFor, narrowTerminalNotice, STICKY_PREVIEW_MAX_ROWS, } from '@gaunt-sloth/core/core/shell/framing.js';
|
|
10
|
+
import { writeDebugDump } from '@gaunt-sloth/core/utils/debugDump.js';
|
|
4
11
|
import { appendToFile, getCommandOutputFilePath } from '@gaunt-sloth/core/utils/fileUtils.js';
|
|
5
|
-
import {
|
|
12
|
+
import { openConversationSafe, recordSessionSafe, } from '@gaunt-sloth/core/history/recordSession.js';
|
|
13
|
+
import { createInterface, error, exit, getProjectDir, getUseColour, refStdin, setRawMode, stdin as input, stdout as output, } from '@gaunt-sloth/core/utils/systemUtils.js';
|
|
6
14
|
import { HumanMessage } from '@langchain/core/messages';
|
|
7
15
|
import { MemorySaver } from '@langchain/langgraph';
|
|
8
16
|
import { createResolvers } from '#src/resolvers.js';
|
|
9
|
-
import {
|
|
17
|
+
import { resolveAgentFactory } from '#src/core/resolveAgentFactory.js';
|
|
18
|
+
import { approvalsRungNotice, approvalsStatusNotice, approvalsTrustNotice, createCommandRegistry, dispatchSlashCommand, formatConfigSummary, parseSlashCommand, } from '#src/modules/slashCommands.js';
|
|
19
|
+
/**
|
|
20
|
+
* [[TUI-C26]] §6 — the `display*` channel an escalation's severity is written on.
|
|
21
|
+
*
|
|
22
|
+
* This surface has no colour of its own: `consoleUtils` owns it per channel, so choosing the
|
|
23
|
+
* channel IS choosing the colour (red for `displayError`, yellow for `displayWarning`, dim for
|
|
24
|
+
* `displayInfo`).
|
|
25
|
+
*
|
|
26
|
+
* **The channel is also a STREAM, and they are not all the same one.** `displayError`,
|
|
27
|
+
* `displayInfo` and `display` go to **stdout**; `displayWarning` goes to **stderr**. So a dialog
|
|
28
|
+
* that mixes them — a `destructive` heading, the rater's label beneath it, the framed reason under
|
|
29
|
+
* that — is written across both. On a terminal both are unbuffered and synchronous, so the lines
|
|
30
|
+
* land in call order and the dialog reads top to bottom. That stops holding the moment stdout is
|
|
31
|
+
* not a terminal: redirected to a pipe or a file it is block-buffered while stderr is not, so a
|
|
32
|
+
* captured log can carry these lines interleaved differently from the screen. The dialog is a
|
|
33
|
+
* question a human answers on the terminal, and every line of it says what it is, so the order is
|
|
34
|
+
* cosmetic there rather than load-bearing — but a reader diffing a captured log against a session
|
|
35
|
+
* should know why the two do not match.
|
|
36
|
+
*
|
|
37
|
+
* The channel is the observable an assertion can bite on, too: a change that made `catastrophic`
|
|
38
|
+
* look like `destructive` here would have to route both to the same function, which a test can see.
|
|
39
|
+
*/
|
|
40
|
+
const severityChannel = (tone) => tone === 'danger' ? displayError : tone === 'warn' ? displayWarning : displayInfo;
|
|
10
41
|
export async function createInteractiveSession(sessionConfig, commandLineConfigOverrides, message) {
|
|
11
42
|
const config = { ...(await initConfig(commandLineConfigOverrides)) };
|
|
12
43
|
const checkpointSaver = new MemorySaver();
|
|
44
|
+
// GS2-19: open ONE conversation for this interactive session up-front; every turn below is stamped
|
|
45
|
+
// with its id so a multi-turn chat groups under one conversation (not N unrelated rows). Opt-in /
|
|
46
|
+
// fail-soft: a no-op returning undefined unless `history.enabled`, in which case turns fall back to
|
|
47
|
+
// per-turn 1-turn conversations. Never affects a default run.
|
|
48
|
+
const conversationId = openConversationSafe(config, {
|
|
49
|
+
command: sessionConfig.mode,
|
|
50
|
+
project: getProjectDir(),
|
|
51
|
+
model: config.modelDisplayName,
|
|
52
|
+
}) ?? undefined;
|
|
13
53
|
// Initialize Runner
|
|
14
54
|
const logFileName = getCommandOutputFilePath(config, sessionConfig.mode);
|
|
15
55
|
if (logFileName) {
|
|
16
56
|
initSessionLogging(logFileName, config.streamSessionInferenceLog);
|
|
17
57
|
}
|
|
18
|
-
|
|
58
|
+
// B5: interactive code/chat default to the LEAN backend; an explicit config.agent.backend
|
|
59
|
+
// overrides it (deep is now opt-in / experimental). createResolvers() is unchanged, so a lean
|
|
60
|
+
// session keeps the full toolset (filesystem + hardened dev/shell).
|
|
61
|
+
const runner = new GthAgentRunner(defaultStatusCallback, createResolvers(), resolveAgentFactory(config, 'lean'));
|
|
19
62
|
try {
|
|
20
63
|
await runner.init(sessionConfig.mode, config, checkpointSaver);
|
|
21
64
|
const rl = createInterface({ input, output });
|
|
22
65
|
let shouldExit = false;
|
|
66
|
+
// GS2-8 — the readline surface shares the SAME command registry as the Ink TUI (one source
|
|
67
|
+
// of truth): every registered command parses, appears in /help, and dispatches here too.
|
|
68
|
+
const registry = createCommandRegistry();
|
|
69
|
+
// Committed-turn counter for the /status command (mirrors the TUI's status-bar counter).
|
|
70
|
+
let turnCount = 0;
|
|
71
|
+
// GS2-56 — wire `/debug-dump` on the readline (`--no-tui`) surface too. Previously this surface
|
|
72
|
+
// injected no writer, so `/debug-dump` reported itself "unavailable" here; it now forwards to the
|
|
73
|
+
// same core writer the TUI uses AND threads the agent's always-on last-model-request snapshot
|
|
74
|
+
// (read at CALL time from the live agent), so the archive carries the full model input even
|
|
75
|
+
// though this surface keeps no on-screen transcript. Fail-soft: no agent handle ⇒ the snapshot is
|
|
76
|
+
// simply omitted (the other artifacts still write). `redact` is resolved by the shared command.
|
|
77
|
+
const dumpDebugSession = (dumpInput) => {
|
|
78
|
+
const agent = runner.getAgent();
|
|
79
|
+
return writeDebugDump({
|
|
80
|
+
transcript: dumpInput.transcript,
|
|
81
|
+
config: dumpInput.config,
|
|
82
|
+
modelDisplayName: dumpInput.modelDisplayName,
|
|
83
|
+
redact: dumpInput.redact,
|
|
84
|
+
modelRequest: agent instanceof GthAbstractAgent ? agent.lastModelRequest : undefined,
|
|
85
|
+
// [[TUI-C27]] — the approvals gate's record of every gated decision, read from the live
|
|
86
|
+
// runner at CALL time for the same reason the model request is.
|
|
87
|
+
approvals: runner.getApprovalCaptures(),
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
// EXT-18: ref stdin before every rl.question() that can run AFTER an agent turn/stream end.
|
|
91
|
+
// When a run suspends (tool-approval interrupt) or throws, the stream's finally calls
|
|
92
|
+
// stopWaitingForEscape(), which unref's stdin so one-shot commands can exit. A prompt that
|
|
93
|
+
// follows must re-ref stdin first, otherwise nothing keeps the event loop alive and the
|
|
94
|
+
// process exits to the shell before the user can answer. The main `> ` loop is safe because
|
|
95
|
+
// its setRawMode(true) already re-refs; these cooked-mode prompts do not, so they ref here.
|
|
96
|
+
const askLine = (prompt) => {
|
|
97
|
+
refStdin();
|
|
98
|
+
return rl.question(prompt);
|
|
99
|
+
};
|
|
23
100
|
// Tool-approval (human-in-the-loop) prompt for gated tools — currently the opt-in
|
|
24
101
|
// `run_shell_command`. When a run suspends on such a tool call, the runner calls this with
|
|
25
102
|
// the pending command. EXT-9 Tier-2: instead of a bare y/N, offer a scoped choice so the
|
|
26
103
|
// human can stop re-prompting for an operation they trust:
|
|
27
|
-
// [o]nce
|
|
28
|
-
// [s]ession
|
|
29
|
-
// [a]lways
|
|
30
|
-
//
|
|
104
|
+
// [o]nce — approve this single invocation only (persists nothing),
|
|
105
|
+
// [s]ession — auto-approve this exact command for the rest of the session,
|
|
106
|
+
// [a]lways — additionally persist it to the project allow-list,
|
|
107
|
+
// [d]eny always — refuse it AND record a deny entry for the rest of the session,
|
|
108
|
+
// anything else → reject this one call (fail-closed, and it stays the fallthrough).
|
|
31
109
|
// The runner consults the allow-list BEFORE calling this, so trusted commands never reach
|
|
32
110
|
// this prompt at all. (The Ink TUI surfaces the same scoped prompt via an approval bridge —
|
|
33
111
|
// see tuiSessionModule's createApprovalBridge + the <ApprovalPrompt> component.)
|
|
@@ -35,30 +113,293 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
|
|
|
35
113
|
const commandText = typeof pending.args.command === 'string'
|
|
36
114
|
? pending.args.command
|
|
37
115
|
: JSON.stringify(pending.args);
|
|
116
|
+
// [[TUI-C26]] §6 — the command is model-authored text going to a terminal, where it is not
|
|
117
|
+
// inert: a carriage return reaches column 0, an escape sequence clears the screen, and a
|
|
118
|
+
// newline alone lays down a line that looks exactly like this prompt's own chrome. It is
|
|
119
|
+
// painted through core's framing renderer — neutralised, inside a line-number gutter, with
|
|
120
|
+
// its command-substitution and composition sites listed above it — the SAME renderer the Ink
|
|
121
|
+
// prompt uses, so the two surfaces cannot differ about how much of a command a human saw.
|
|
122
|
+
// Nothing is clamped to one line: the command that motivated this hid its payload fifteen
|
|
123
|
+
// lines into a commit message, and a clamp discards exactly what the human must rule on.
|
|
124
|
+
const frameWidth = frameWidthFor(output.columns);
|
|
125
|
+
const framedCommand = frameUntrustedCommand(commandText, { width: frameWidth });
|
|
38
126
|
displayWarning(`\nThe agent wants to run a shell command via ${pending.name}:`);
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
127
|
+
// Below core's floor the frame is wider than the terminal, which wraps it and puts untrusted
|
|
128
|
+
// text at the left edge. The frame is still shown — hiding what the human must rule on would
|
|
129
|
+
// be worse — but the guarantee has lapsed, and it says so instead of lapsing silently.
|
|
130
|
+
const tooNarrow = narrowTerminalNotice(output.columns);
|
|
131
|
+
if (tooNarrow)
|
|
132
|
+
displayWarning(tooNarrow);
|
|
133
|
+
for (const notice of framedCommand.notices)
|
|
134
|
+
displayWarning(notice);
|
|
135
|
+
display('');
|
|
136
|
+
for (const line of framedCommand.lines)
|
|
137
|
+
display(line);
|
|
138
|
+
display('');
|
|
139
|
+
// CFG-27: when the auto-rater escalated this command (rather than approving it), show its
|
|
140
|
+
// outcome + reason before the human decides. §6 makes that explanation mandatory whenever a
|
|
141
|
+
// rating exists; at the unrated rungs there is none and the prompt shows the command alone.
|
|
142
|
+
// The outcome is a schema enum; the reason is model-authored prose and is framed exactly like
|
|
143
|
+
// the command, because a dialog forgeable through the string that explains it is not a gate.
|
|
144
|
+
// [[TUI-C26]] §6 — the severity is legible in three independent ways: a glyph, a sentence of
|
|
145
|
+
// the gate's own naming what the outcome MEANS, and the channel it is written on. The channel
|
|
146
|
+
// is this surface's colour: `catastrophic` goes to `displayError` (red) where `destructive`
|
|
147
|
+
// goes to `displayWarning` (yellow), so the two cannot look alike — and the sentence carries
|
|
148
|
+
// it anyway for a terminal with no colour at all, which is the one that must not be left out.
|
|
42
149
|
if (pending.safetyVerdict) {
|
|
43
|
-
|
|
150
|
+
const severity = describeRaterOutcome(pending.safetyVerdict.outcome);
|
|
151
|
+
const say = severityChannel(severity.tone);
|
|
152
|
+
say(severity.heading);
|
|
153
|
+
// The reason is the RATER's, and now that the line above it is the gate's own sentence the
|
|
154
|
+
// attribution has to be said rather than implied.
|
|
155
|
+
displayInfo(RATER_REASON_LABEL);
|
|
156
|
+
for (const line of frameUntrustedText(pending.safetyVerdict.reason, { width: frameWidth })
|
|
157
|
+
.lines) {
|
|
158
|
+
say(line);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// EXT-71 §3.2 — when a declared `approvals.escalate` entry is what brought this call here,
|
|
162
|
+
// the prompt shows THE ENTRY THAT FIRED. Without it the user is asked about a command their
|
|
163
|
+
// rung would have approved, with nothing on screen tying the question to the line they wrote
|
|
164
|
+
// — which reads as the gate malfunctioning rather than as their own rule working.
|
|
165
|
+
// [[TUI-C26]] — framed rather than interpolated. The entry is usually something the user
|
|
166
|
+
// wrote, but an MCP entry can carry server-supplied names, and this line sits one string away
|
|
167
|
+
// from the prompt's own chrome. The label stays this surface's own line.
|
|
168
|
+
if (pending.escalatedBy) {
|
|
169
|
+
displayWarning('⚠ Your approvals.escalate list matched this call:');
|
|
170
|
+
for (const line of frameUntrustedText(pending.escalatedBy, { width: frameWidth }).lines) {
|
|
171
|
+
displayWarning(line);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// [[EXT-29]] §6 — when a §5 negotiation preceded this escalation, the human is shown ALL of
|
|
175
|
+
// it. The user is not asked to rule on the final command in isolation: that the agent
|
|
176
|
+
// proposed the same command three times unchanged, against two rejections that each told it
|
|
177
|
+
// what to fix, is the most important thing on the screen and is invisible if only the last
|
|
178
|
+
// attempt is shown. Rendered through core's shared renderer, so the surfaces cannot describe
|
|
179
|
+
// one exchange two ways.
|
|
180
|
+
//
|
|
181
|
+
// [[TUI-C26]] §5.4 — rendered as ROWS, so the two voices are told apart: the rater's turns go
|
|
182
|
+
// to the warn channel (yellow) and the agent's to the plain one, which is what the spec asks
|
|
183
|
+
// for and what one joined string could not express — the whole exchange used to arrive in a
|
|
184
|
+
// single colour. The rows are bound to the terminal width for the same reason the command is:
|
|
185
|
+
// a long justification left to the terminal's own wrap continues at column 0.
|
|
186
|
+
for (const row of renderNegotiationRows(pending.negotiationRounds ?? [], {
|
|
187
|
+
width: frameWidth,
|
|
188
|
+
})) {
|
|
189
|
+
if (row.voice === 'rater')
|
|
190
|
+
displayWarning(row.text);
|
|
191
|
+
else if (row.voice === 'agent')
|
|
192
|
+
display(row.text);
|
|
193
|
+
else
|
|
194
|
+
displayInfo(row.text);
|
|
195
|
+
}
|
|
196
|
+
// EXT-71/EXT-70 §6 — the menu MUST show what a sticky choice will store, at the moment of
|
|
197
|
+
// the choice, and it names it in the words the control is written in: the command itself for
|
|
198
|
+
// a shell call, the tool (with its server and host bound) for a tool call, since for a tool
|
|
199
|
+
// "the stored thing is the tool, not the arguments" (§4.7.4). The exact entry follows it, so
|
|
200
|
+
// the user sees the thing they are agreeing to rather than a generalization of it.
|
|
201
|
+
const sticky = pending.grantPreview !== undefined;
|
|
202
|
+
if (sticky) {
|
|
203
|
+
// [[TUI-C26]] — these two lines carry the command as typed (§3.1 stores it exactly, never a
|
|
204
|
+
// widened pattern), so they inherit the command's problem in less space: an in-line
|
|
205
|
+
// `approved by rater` fits on one line untouched. Framed like everything else, with the
|
|
206
|
+
// label kept as this surface's OWN line so the untrusted half can never be read as chrome.
|
|
207
|
+
// Bounded to a few rows: for a shell call these carry the command as typed, which is
|
|
208
|
+
// already printed in full above — so an unbounded copy of a long command here (twice over,
|
|
209
|
+
// since the entry repeats it) scrolls the MENU off the screen, and a control the human
|
|
210
|
+
// cannot see is not one they were offered.
|
|
211
|
+
displayInfo('[s]/[a] will remember:');
|
|
212
|
+
for (const line of frameUntrustedText(pending.grantSummary ?? pending.grantPreview, {
|
|
213
|
+
width: frameWidth,
|
|
214
|
+
maxRows: STICKY_PREVIEW_MAX_ROWS,
|
|
215
|
+
}).lines) {
|
|
216
|
+
displayInfo(line);
|
|
217
|
+
}
|
|
218
|
+
displayInfo(' stored as:');
|
|
219
|
+
for (const line of frameUntrustedText(pending.grantPreview, {
|
|
220
|
+
width: frameWidth,
|
|
221
|
+
maxRows: STICKY_PREVIEW_MAX_ROWS,
|
|
222
|
+
}).lines) {
|
|
223
|
+
displayInfo(line);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// [[TUI-C26]] §6 — the same requirement for the OTHER sticky choice, and its availability is
|
|
227
|
+
// a different question: the runner offers a deny entry in cases where no grant exists at all
|
|
228
|
+
// (a command that does not statically resolve, every `catastrophic` verdict), because a
|
|
229
|
+
// refusal that cannot be decided still refuses. `recorded as:` rather than a second
|
|
230
|
+
// `stored as:` — one dialog, two labels that read alike, is how a reader loses track of which
|
|
231
|
+
// block they are looking at. The label states the lifetime because there is no persisted deny
|
|
232
|
+
// store and the control must not imply one.
|
|
233
|
+
const stickyDeny = pending.denyPreview !== undefined;
|
|
234
|
+
if (stickyDeny) {
|
|
235
|
+
displayInfo('[d] will refuse, for the rest of this session:');
|
|
236
|
+
for (const line of frameUntrustedText(pending.denySummary ?? pending.denyPreview, {
|
|
237
|
+
width: frameWidth,
|
|
238
|
+
maxRows: STICKY_PREVIEW_MAX_ROWS,
|
|
239
|
+
}).lines) {
|
|
240
|
+
displayInfo(line);
|
|
241
|
+
}
|
|
242
|
+
displayInfo(' recorded as:');
|
|
243
|
+
for (const line of frameUntrustedText(pending.denyPreview, {
|
|
244
|
+
width: frameWidth,
|
|
245
|
+
maxRows: STICKY_PREVIEW_MAX_ROWS,
|
|
246
|
+
}).lines) {
|
|
247
|
+
displayInfo(line);
|
|
248
|
+
}
|
|
44
249
|
}
|
|
45
250
|
setRawMode(false); // ensure typed input is echoed for this confirm
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
251
|
+
// EXT-18: wrap the prompt in try/finally so the raw-mode/ref state is not left wedged if
|
|
252
|
+
// rl.question throws. The subsequent streamResume run re-establishes raw mode + ref, but be
|
|
253
|
+
// defensive. askLine() refs stdin first so the prompt actually waits for input (the run just
|
|
254
|
+
// suspended on the tool interrupt, whose stream-end unref'd stdin).
|
|
255
|
+
let answer;
|
|
256
|
+
try {
|
|
257
|
+
// §6 — a sticky control is SHOWN only where the gate would actually store something. Where
|
|
258
|
+
// nothing would be remembered — a `catastrophic` outcome (§4.2), a command that does not
|
|
259
|
+
// statically resolve, a tool call nothing can attribute — the menu simply does not offer
|
|
260
|
+
// the choice, because "a control that is offered and then refused reads as a bug rather
|
|
261
|
+
// than as a policy". Hiding it, never disabling it: a disabled control invites the user to
|
|
262
|
+
// hunt for why. The two sticky controls are judged SEPARATELY: the deny entry exists in
|
|
263
|
+
// cases where no grant does, which is the whole reason it is a second condition.
|
|
264
|
+
//
|
|
265
|
+
// Assembled from parts rather than by enumerating the four combinations, so a menu spelling
|
|
266
|
+
// nobody wrote down cannot reach a terminal.
|
|
267
|
+
const controls = [
|
|
268
|
+
'[o]nce',
|
|
269
|
+
...(sticky ? ['[s]ession', '[a]lways'] : []),
|
|
270
|
+
'[N]o',
|
|
271
|
+
...(stickyDeny ? ['[d]eny always'] : []),
|
|
272
|
+
];
|
|
273
|
+
answer = (await askLine(formatInputPrompt(`Approve? ${controls.join(' / ')}: `)))
|
|
274
|
+
.trim()
|
|
275
|
+
.toLowerCase();
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
refStdin();
|
|
279
|
+
}
|
|
49
280
|
if (answer === 'o' || answer === 'once') {
|
|
50
281
|
return { type: 'approve', scope: 'once' };
|
|
51
282
|
}
|
|
52
|
-
|
|
53
|
-
|
|
283
|
+
// CFG-28 (§4.2) / [[TUI-C26]] §1.1 — **a sticky approve is answerable only where the control
|
|
284
|
+
// was OFFERED**, on the same condition that decides whether it was shown. `catastrophic` is
|
|
285
|
+
// one case of it: the runner clamps the allow-list write for that outcome, so a grant is
|
|
286
|
+
// never stored and no grant is on offer — and the others are a command that does not
|
|
287
|
+
// statically resolve and a call nothing can attribute. Typed on a menu that does not carry
|
|
288
|
+
// them, `s` and `a` are unbound answers like any other and fall through to the one-shot
|
|
289
|
+
// refusal below.
|
|
290
|
+
//
|
|
291
|
+
// **The gate has to be on the key, not only on the confirmation.** Bound anywhere else, `a`
|
|
292
|
+
// at a `catastrophic` prompt approves and RUNS the command — nothing between this callback
|
|
293
|
+
// and execution re-reads the verdict — off a menu that has already withdrawn the choice,
|
|
294
|
+
// which is §6's "a control that is offered and then refused" with the withdrawal made
|
|
295
|
+
// cosmetic. The scope is still returned exactly as typed: the clamp on what gets STORED is
|
|
296
|
+
// core's, the single chokepoint for every surface, and this must not start deciding
|
|
297
|
+
// persistence for itself.
|
|
298
|
+
if (sticky && (answer === 's' || answer === 'session')) {
|
|
299
|
+
displayInfo('Approved — this exact command will not ask again this session.');
|
|
54
300
|
return { type: 'approve', scope: 'session' };
|
|
55
301
|
}
|
|
56
|
-
if (answer === 'a' || answer === 'always') {
|
|
57
|
-
displayInfo('Approved and remembered — saved to the project allow-list.');
|
|
302
|
+
if (sticky && (answer === 'a' || answer === 'always')) {
|
|
303
|
+
displayInfo('Approved and remembered — this exact command is saved to the project allow-list.');
|
|
58
304
|
return { type: 'approve', scope: 'always' };
|
|
59
305
|
}
|
|
60
|
-
|
|
61
|
-
|
|
306
|
+
// [[TUI-C26]] §6 — *always reject*: a refusal that is also recorded, so the next identical
|
|
307
|
+
// call is refused by rule without reaching a person. Answered only where the control was
|
|
308
|
+
// OFFERED; typed anywhere else it is an unbound answer like any other and falls through to
|
|
309
|
+
// the one-shot refusal below, which is what keeps the safe action the fallthrough.
|
|
310
|
+
//
|
|
311
|
+
// **One spelling for the control this work adds** — `d`, the one the menu prints. A second
|
|
312
|
+
// that the menu never advertises is how this surface starts drifting from the Ink one, which
|
|
313
|
+
// has no aliases at all.
|
|
314
|
+
//
|
|
315
|
+
// That rule governs what is ADDED here, and the long forms `once`, `session` and `always`
|
|
316
|
+
// accepted above are not exceptions to it: they predate this work and they stay. Each is
|
|
317
|
+
// gated by exactly the condition that gates its own letter — `session`/`always` inside the
|
|
318
|
+
// same `sticky` test as `s`/`a`, `once` on a control that is always offered — so none of them
|
|
319
|
+
// widens what is answerable at any prompt. Read this as the reason not to add a fourth alias,
|
|
320
|
+
// never as licence to delete the three that are here.
|
|
321
|
+
const stickyRejected = stickyDeny && answer === 'd';
|
|
322
|
+
// The confirmation says what actually happened and stops there. There is no persisted deny
|
|
323
|
+
// store, so a line implying one would be the same failure §6 names when it calls a control
|
|
324
|
+
// offered and then refused a bug — with the evidence hidden, which is worse.
|
|
325
|
+
displayInfo(stickyRejected
|
|
326
|
+
? 'Refused — this call will not run for the rest of this session, and will not ask ' +
|
|
327
|
+
'again. Nothing was saved to the project, so a new session will ask about it again.'
|
|
328
|
+
: 'Command rejected.');
|
|
329
|
+
// EXT-58 (§7): the model is told the moves it has — re-call with a justification, call a
|
|
330
|
+
// different command, or ask the user — and, when the rater named an already-granted
|
|
331
|
+
// alternative (§4.4), that tool plus the clause saying it needs no approval. A bare "user
|
|
332
|
+
// rejected" leaves the model to guess, which it does by repeating itself or giving up.
|
|
333
|
+
return {
|
|
334
|
+
type: 'reject',
|
|
335
|
+
...(stickyRejected ? { scope: 'session' } : {}),
|
|
336
|
+
message: buildRejectionMessage({
|
|
337
|
+
source: 'user',
|
|
338
|
+
toolName: pending.name,
|
|
339
|
+
verdict: pending.safetyVerdict,
|
|
340
|
+
}),
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
// [[TUI-C68]] §6.1 — the ATTACK BANNER on the plain surface. An `attack` verdict says the
|
|
344
|
+
// command's own structure evidenced compromise, and it ends the run; without this the only
|
|
345
|
+
// recovery is a restart, which §12 forbids. Wiring the callback is what opts this session into
|
|
346
|
+
// being asked — every surface that does not wire it keeps the halt (§6.2), so forgetting fails
|
|
347
|
+
// safe rather than opening a hole.
|
|
348
|
+
//
|
|
349
|
+
// **It is not the approval prompt and must not read like one.** No menu, no scope, no key: one
|
|
350
|
+
// typed phrase runs one command, and everything else stops the run — including a bare Enter and
|
|
351
|
+
// any near miss. There is no second attempt on purpose: a re-prompt turns a typo into another
|
|
352
|
+
// chance at an irreversible action.
|
|
353
|
+
//
|
|
354
|
+
// On this surface `rl.question` reads a whole LINE in cooked mode, so `q` and `Esc` are not
|
|
355
|
+
// keystrokes it can intercept — they are simply text that is not the phrase, and stop the run
|
|
356
|
+
// like any other. That is why the shared copy carries no keyboard line: the Ink TUI adds its own
|
|
357
|
+
// keys beside these, and a line here naming keys this surface cannot honour would be false.
|
|
358
|
+
runner.setAttackHaltCallback(async (halt) => {
|
|
359
|
+
const copy = attackBannerCopy();
|
|
360
|
+
const frameWidth = frameWidthFor(output.columns);
|
|
361
|
+
displayError(`\n${copy.title}`);
|
|
362
|
+
const tooNarrow = narrowTerminalNotice(output.columns);
|
|
363
|
+
if (tooNarrow)
|
|
364
|
+
displayWarning(tooNarrow);
|
|
365
|
+
// The command and the rater's reason are model-authored text on the last screen between a
|
|
366
|
+
// human and the action. They go through the SAME framing renderer as the approval dialog —
|
|
367
|
+
// neutralised, gutter-numbered, substitution and composition sites listed above — because a
|
|
368
|
+
// banner whose own chrome can be forged by the string it is warning about is worse than none.
|
|
369
|
+
const framedCommand = frameUntrustedCommand(halt.command, { width: frameWidth });
|
|
370
|
+
for (const notice of framedCommand.notices)
|
|
371
|
+
displayWarning(notice);
|
|
372
|
+
display('');
|
|
373
|
+
for (const line of framedCommand.lines)
|
|
374
|
+
display(line);
|
|
375
|
+
display('');
|
|
376
|
+
displayError(copy.heading);
|
|
377
|
+
displayInfo(RATER_REASON_LABEL);
|
|
378
|
+
for (const line of frameUntrustedText(halt.reason, { width: frameWidth }).lines) {
|
|
379
|
+
displayError(line);
|
|
380
|
+
}
|
|
381
|
+
// UNCONDITIONAL, on every attack banner whatever the rating said. The banner is rare by
|
|
382
|
+
// construction, so the line cannot become noise, and a static string cannot fail the way a
|
|
383
|
+
// model's explanation can.
|
|
384
|
+
displayError(copy.irreversible);
|
|
385
|
+
for (const line of copy.controls)
|
|
386
|
+
displayInfo(line);
|
|
387
|
+
setRawMode(false); // ensure the typed phrase is echoed
|
|
388
|
+
let answer;
|
|
389
|
+
try {
|
|
390
|
+
answer = await askLine(formatInputPrompt(copy.prompt));
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
refStdin();
|
|
394
|
+
}
|
|
395
|
+
// One shot. The matcher is core's, so what the banner SAYS is answerable and what this
|
|
396
|
+
// surface ACCEPTS cannot drift, and every value of the line that is not the phrase is a
|
|
397
|
+
// refusal — which is what keeps the safe answer the fallthrough on a control that otherwise
|
|
398
|
+
// accumulates keystrokes instead of rejecting them.
|
|
399
|
+
if (!grantsRunAnyway(answer))
|
|
400
|
+
return 'stop';
|
|
401
|
+
displayWarning(copy.granted);
|
|
402
|
+
return 'run-anyway';
|
|
62
403
|
});
|
|
63
404
|
if (logFileName) {
|
|
64
405
|
displayInfo(`${sessionConfig.mode} session will be logged to ${logFileName}\n`);
|
|
@@ -74,7 +415,54 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
|
|
|
74
415
|
// longer injected here as a per-turn SystemMessage (which yielded a second, non-first system
|
|
75
416
|
// message that Anthropic rejects).
|
|
76
417
|
const messages = [new HumanMessage(userInput)];
|
|
77
|
-
|
|
418
|
+
// GS2-18: wire the readline (`--no-tui`) interactive path into the opt-in history recorder
|
|
419
|
+
// at its turn boundary, matching the single-shot and Ink-TUI paths. Fail-soft and
|
|
420
|
+
// default-OFF (recordSessionSafe is a no-op unless `history.enabled`), so a default run is
|
|
421
|
+
// unchanged. GS2-16 threads live token/tool/duration analytics; costUsd stays unset.
|
|
422
|
+
const startedAt = Date.now();
|
|
423
|
+
const responseText = await runner.processMessages(messages);
|
|
424
|
+
let runStats = { tools: [] };
|
|
425
|
+
try {
|
|
426
|
+
const s = runner.getRunStats?.();
|
|
427
|
+
if (s)
|
|
428
|
+
runStats = s;
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
/* fail-soft: analytics must never affect the session */
|
|
432
|
+
}
|
|
433
|
+
recordSessionSafe(config, {
|
|
434
|
+
conversationId, // GS2-19: group every turn under this session's conversation
|
|
435
|
+
command: sessionConfig.mode,
|
|
436
|
+
project: getProjectDir(),
|
|
437
|
+
model: config.modelDisplayName,
|
|
438
|
+
prompt: userInput,
|
|
439
|
+
response: responseText,
|
|
440
|
+
tokensInput: runStats.tokensInput,
|
|
441
|
+
tokensOutput: runStats.tokensOutput,
|
|
442
|
+
tools: runStats.tools.length > 0 ? runStats.tools : undefined,
|
|
443
|
+
durationMs: Date.now() - startedAt,
|
|
444
|
+
});
|
|
445
|
+
turnCount += 1; // GS2-8 — feeds the /status turn counter
|
|
446
|
+
};
|
|
447
|
+
// GS2-8 — render a structured command notice on the plain-text surface: tone-matched title
|
|
448
|
+
// (warn ⇒ yellow), then the body lines indented under it.
|
|
449
|
+
const printNotice = (notice) => {
|
|
450
|
+
if (notice.tone === 'warn') {
|
|
451
|
+
displayWarning(notice.title);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
displayInfo(notice.title);
|
|
455
|
+
}
|
|
456
|
+
for (const line of notice.lines) {
|
|
457
|
+
display(` ${line}`);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
const endSession = async () => {
|
|
461
|
+
display('Exiting...');
|
|
462
|
+
shouldExit = true;
|
|
463
|
+
await runner.cleanup();
|
|
464
|
+
stopSessionLogging();
|
|
465
|
+
rl.close();
|
|
78
466
|
};
|
|
79
467
|
const askQuestion = async () => {
|
|
80
468
|
while (!shouldExit) {
|
|
@@ -83,28 +471,82 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
|
|
|
83
471
|
if (!userInput.trim()) {
|
|
84
472
|
continue; // Skip inference if no input
|
|
85
473
|
}
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
shouldExit = true;
|
|
90
|
-
await runner.cleanup();
|
|
91
|
-
stopSessionLogging();
|
|
92
|
-
rl.close();
|
|
474
|
+
// Legacy bare `exit` keyword still quits (parity with the TUI's plain-exit handling).
|
|
475
|
+
if (userInput.toLowerCase().trim() === 'exit') {
|
|
476
|
+
await endSession();
|
|
93
477
|
break;
|
|
94
478
|
}
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
479
|
+
// GS2-8 — every `/command` dispatches through the SAME registry as the Ink TUI (single
|
|
480
|
+
// source of truth). parseSlashCommand's `/`-vs-path heuristic means a pasted filesystem
|
|
481
|
+
// path (`/usr/home/bob/test.md`) is NOT a command and falls through to the model below.
|
|
482
|
+
const parsed = parseSlashCommand(userInput);
|
|
483
|
+
if (parsed) {
|
|
484
|
+
const result = dispatchSlashCommand(parsed, registry, {
|
|
485
|
+
mode: sessionConfig.mode,
|
|
486
|
+
modelDisplayName: config.modelDisplayName ?? '',
|
|
487
|
+
turnCount,
|
|
488
|
+
// No tool-detail panels or debug pane exist on this surface; their commands degrade
|
|
489
|
+
// below rather than vanishing from the catalog.
|
|
490
|
+
toolsExpanded: false,
|
|
491
|
+
debugVisible: false,
|
|
492
|
+
// CFG-25 — pass the session command so the panel prints the EFFECTIVE per-command
|
|
493
|
+
// filesystem value (e.g. `all` for `code`), not the top-level default.
|
|
494
|
+
configSummary: formatConfigSummary(config, sessionConfig.mode),
|
|
495
|
+
// GS2-56 — `/debug-dump` is now available here. This surface keeps no on-screen
|
|
496
|
+
// transcript array, so `transcript` is empty; the real as-sent history lands in the
|
|
497
|
+
// archive's model-messages.json from the always-on snapshot (that is the point).
|
|
498
|
+
transcript: [],
|
|
499
|
+
resolvedConfig: config,
|
|
500
|
+
dumpDebugSession,
|
|
501
|
+
});
|
|
502
|
+
if (result.exit) {
|
|
503
|
+
await endSession();
|
|
504
|
+
break;
|
|
103
505
|
}
|
|
104
|
-
|
|
105
|
-
|
|
506
|
+
if (result.approvals) {
|
|
507
|
+
// CFG-27 — `/approvals <rung>` sets the session rung at the approval-decision layer
|
|
508
|
+
// (the runner posture). Session-scoped, reversible, never persisted. With no argument
|
|
509
|
+
// the command DISPLAYS the posture instead of changing it.
|
|
510
|
+
if ('show' in result.approvals) {
|
|
511
|
+
printNotice(approvalsStatusNotice(runner.getSessionApprovals(), runner.getAllowlistCounts(), runner.getDenylist(), runner.getGrants(), runner.getMcpAnnotationTrust()));
|
|
512
|
+
}
|
|
513
|
+
else if ('trust' in result.approvals) {
|
|
514
|
+
// EXT-70 §4.7.1 — believe (or stop believing) specific hints from one server, for
|
|
515
|
+
// this session. The notice is built from what the runner RETURNS, so it can only
|
|
516
|
+
// describe the trust actually in force — including §4.7.4's consequence when the
|
|
517
|
+
// withdrawal is a weakening.
|
|
518
|
+
const { server, hints, believe } = result.approvals.trust;
|
|
519
|
+
printNotice(approvalsTrustNotice(runner.setMcpAnnotationTrust(server, hints, believe)));
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
runner.setSessionApprovalRung(result.approvals.rung);
|
|
523
|
+
// Report the posture the runner actually LANDED on, not the one requested.
|
|
524
|
+
printNotice(approvalsRungNotice(runner.getSessionApprovals()));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else if (result.clearTranscript ||
|
|
528
|
+
result.toggleDebug ||
|
|
529
|
+
result.toggleTools ||
|
|
530
|
+
result.reprintReasoning) {
|
|
531
|
+
// TUI-only effects (transcript clear, debug pane, tool-detail fold, reasoning
|
|
532
|
+
// reprint) have no equivalent on the plain readline surface — degrade with a clear
|
|
533
|
+
// pointer instead of silently doing nothing (GS2-8).
|
|
534
|
+
displayInfo(`/${parsed.name} is not available without the TUI — start the session on an ` +
|
|
535
|
+
`interactive terminal without --no-tui/GTH_NO_TUI to use it.`);
|
|
536
|
+
}
|
|
537
|
+
else if (result.notice) {
|
|
538
|
+
printNotice(result.notice);
|
|
106
539
|
}
|
|
107
|
-
|
|
540
|
+
if (result.message) {
|
|
541
|
+
// Incidental system line (e.g. the /tools→/verbose deprecation pointer).
|
|
542
|
+
if (result.level === 'warning') {
|
|
543
|
+
displayWarning(result.message);
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
displayInfo(result.message);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
continue; // never send a slash command to the model
|
|
108
550
|
}
|
|
109
551
|
let shouldRetry = false;
|
|
110
552
|
do {
|
|
@@ -114,7 +556,10 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
|
|
|
114
556
|
}
|
|
115
557
|
catch (err) {
|
|
116
558
|
display(`\n❌ Error processing message: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
117
|
-
|
|
559
|
+
// EXT-18: askLine() refs stdin first. This prompt runs in the catch after
|
|
560
|
+
// processMessage threw, by which point the stream's finally has already unref'd
|
|
561
|
+
// stdin (same exit as the approval prompt) - re-ref so it waits for input.
|
|
562
|
+
const retryResponse = await askLine('Do you want to try again with the same prompt? (y/n): ');
|
|
118
563
|
shouldRetry = retryResponse.toLowerCase().trim().startsWith('y');
|
|
119
564
|
if (!shouldRetry) {
|
|
120
565
|
display('\nSkipping to next prompt...');
|
|
@@ -132,6 +577,21 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
|
|
|
132
577
|
await processMessage(message);
|
|
133
578
|
}
|
|
134
579
|
else {
|
|
580
|
+
// TUI-C33 — the ASCII-art launch banner, ABOVE the untouched ready message. Only on this
|
|
581
|
+
// branch: an `-m` run goes straight to work, which is the readline twin of the TUI hiding
|
|
582
|
+
// its intro when it mounts with an initialMessage.
|
|
583
|
+
//
|
|
584
|
+
// Gated on `stdout.isTTY` (as the TUI's viewport bump is) so piped, redirected and non-TTY
|
|
585
|
+
// runs stay clean, and on `getUseColour()` for the escapes, so a monochrome session degrades
|
|
586
|
+
// the banner to plain text instead of dropping it. `stdout.columns` is what every field is
|
|
587
|
+
// truncated against — see launchBanner.ts on why a wrapped line would shatter the art.
|
|
588
|
+
if (output.isTTY) {
|
|
589
|
+
displayLaunchBanner(launchBannerText({
|
|
590
|
+
...launchBannerFields(config.modelDisplayName, config.modelProviderType),
|
|
591
|
+
columns: output.columns,
|
|
592
|
+
colour: getUseColour(),
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
135
595
|
display(sessionConfig.readyMessage);
|
|
136
596
|
displayInfo(sessionConfig.exitMessage);
|
|
137
597
|
}
|