dsh-gitbash-shell 0.38.5 → 0.39.0

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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * dsh-gitbash-shell/bash-render — model-facing rendering for the own bash tool.
3
+ *
4
+ * v0.39.0 (route B): the plugin no longer replaces the host `ctx.shell`
5
+ * executor, so it can no longer mount the OFFICIAL `@deepseek-ai/dsh-tool-bash`
6
+ * module (its every execution goes through `ctx.shell`, which stays PowerShell
7
+ * here). Instead the plugin ships its OWN bash tool whose executor is a private
8
+ * `GitBashSandboxExecutor` instance (never registered as a service), and this
9
+ * file mirrors the official tool's rendering contract call-for-call so the
10
+ * model sees byte-identical output shapes:
11
+ *
12
+ * - `render.ts` of `@deepseek-ai/dsh-tool-bash` (result / promoted / job-read
13
+ * text shaping), and
14
+ * - the exit-marker parser of `@deepseek-ai/dsh-shell` (`parseExitStatus`),
15
+ *
16
+ * with the sandbox markers inlined as the exact strings the official
17
+ * `@deepseek-ai/dsh-sandbox` emits (`packages/sandbox/sandbox/src/escalation.ts`).
18
+ * Inlining is deliberate: the markers are stable display text, and pulling a
19
+ * host package at render time would make the model-facing contract depend on a
20
+ * resolution channel that can be absent on exotic layouts. Drift risk is
21
+ * recorded in the smoke suite (marker-format assertions).
22
+ *
23
+ * @module dsh-gitbash-shell/bash-render
24
+ */
25
+
26
+ /** The official sandbox-denial marker (escalation.ts:71). */
27
+ export function sandboxDenialMarker(mode) {
28
+ return `[sandbox: file access denied under ${mode} mode]`
29
+ }
30
+
31
+ /** The official same-turn escalation hint (escalation.ts:84). */
32
+ export function escalationHintMarker(subject) {
33
+ return `[sandbox: escalation available — retry this exact ${subject} once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`
34
+ }
35
+
36
+ /** The official escalation targets (escalation.ts:41). */
37
+ export const ESCALATION_TARGETS = Object.freeze(['workspace-write', 'danger-full-access'])
38
+
39
+ /**
40
+ * The official exit-status parse (dsh-shell render.ts:37): the LAST marker
41
+ * line anchors the split between the output body and the exit pill.
42
+ * @param {string} text - the rendered result text.
43
+ * @returns {{ body: string, exitCode?: number, signal?: string }}
44
+ */
45
+ export function parseExitStatus(text) {
46
+ const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
47
+ if (signal && signal[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
48
+ const exit = /\n\[exit code: (\d+)\]$/.exec(text)
49
+ if (exit && exit[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
50
+ return { body: text, exitCode: 0 }
51
+ }
52
+
53
+ /** Append the truncation notice (with the full-output spill path) to a stream's text. */
54
+ function streamText(output) {
55
+ if (!output || output.truncated !== true) return output ? output.text : ''
56
+ return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
57
+ }
58
+
59
+ /**
60
+ * Shape one finished run into the text the model sees: stdout, then a marked
61
+ * stderr section, then exit-status markers. Non-zero exits are reported, not
62
+ * errored — the model decides how to react; only infrastructure failures
63
+ * (spawn errors, aborts) surface as isError results. Mirrors the official
64
+ * `renderResult` (tool-bash render.ts:28).
65
+ * @param {object} result - the completed foreground run from the executor.
66
+ * @param {string[]} [escalationModes] - the escalation targets this composition advertises.
67
+ * @returns {string} the model-facing text.
68
+ */
69
+ export function renderResult(result, escalationModes = []) {
70
+ const out = streamText(result.stdout)
71
+ const err = streamText(result.stderr)
72
+
73
+ let body = out
74
+ if (err.length > 0) {
75
+ // Single newline between sections (stdout usually ends with one already).
76
+ if (body.length > 0 && !body.endsWith('\n')) body += '\n'
77
+ body += `[stderr]\n${err}`
78
+ }
79
+ if (body.length === 0) body = '(no output)'
80
+
81
+ const markers = []
82
+ // Keep the exit marker last because parseExitStatus anchors there.
83
+ if (result.sandbox && result.sandbox.denied) {
84
+ markers.push(sandboxDenialMarker(result.sandbox.mode))
85
+ if (escalationModes.length > 0) markers.push(escalationHintMarker('command'))
86
+ }
87
+ // A command may trap SIGTERM and exit 0 after timeout; still report interruption.
88
+ if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
89
+ // A kill from outside the call (the human stopping its job) is not a
90
+ // command failure: the reason tells the model not to retry.
91
+ if (result.stopped !== undefined) markers.push(`[stopped: ${result.stopped}]`)
92
+ if (result.signal !== null && result.signal !== undefined) {
93
+ markers.push(`[killed by signal: ${result.signal}]`)
94
+ } else if (result.exitCode !== 0) {
95
+ markers.push(`[exit code: ${result.exitCode}]`)
96
+ }
97
+ if (markers.length === 0) return body
98
+
99
+ if (!body.endsWith('\n')) body += '\n'
100
+ return body + markers.join('\n')
101
+ }
102
+
103
+ /**
104
+ * Shape a foreground call that stopped waiting into the text the model sees.
105
+ * Mirrors the official `renderPromoted` (tool-bash render.ts:77).
106
+ * @param {{ jobId: string, timeoutMs: number, output: string }} promoted
107
+ * @returns {string}
108
+ */
109
+ export function renderPromoted(promoted) {
110
+ const body = promoted.output.length > 0
111
+ ? (promoted.output.endsWith('\n') ? promoted.output : `${promoted.output}\n`)
112
+ : ''
113
+ return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]\n`
114
+ + 'The command keeps running in the background. You will be notified when it finishes; '
115
+ + 'read newer output with job_output, stop it with job_kill.'
116
+ }
117
+
118
+ /**
119
+ * Sandbox facts worth the terminal detail: a runner that never ran the
120
+ * command, or a denial (with the escalation hint this composition offers).
121
+ * Mirrors background.ts:21 `sandboxNotes`.
122
+ */
123
+ function sandboxNotes(sandbox, escalationModes) {
124
+ if (sandbox && sandbox.runnerFailed) {
125
+ return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`]
126
+ }
127
+ if (sandbox && sandbox.denied) {
128
+ const notes = [sandboxDenialMarker(sandbox.mode)]
129
+ if (escalationModes.length > 0) notes.push(escalationHintMarker('command'))
130
+ return notes
131
+ }
132
+ return []
133
+ }
134
+
135
+ /**
136
+ * Map a settled background process onto the generic job-outcome vocabulary.
137
+ * Mirrors background.ts:44 `processOutcome`.
138
+ * @param {object} proc - the settled process handle.
139
+ * @param {string[]} [escalationModes]
140
+ * @returns {object} the outcome for the `ctx.jobs` registration.
141
+ */
142
+ export function processOutcome(proc, escalationModes = []) {
143
+ const base = proc.status === 'killed'
144
+ ? { status: 'killed', detail: proc.signal !== null && proc.signal !== undefined ? `signal: ${proc.signal}` : 'killed before exit' }
145
+ : { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
146
+ const notes = sandboxNotes(proc.sandbox, escalationModes)
147
+ return notes.length === 0 ? base : { ...base, detail: `${base.detail}; ${notes.join(' ')}` }
148
+ }
149
+
150
+ /**
151
+ * The process's non-consuming stream readers as registry pull sources; they
152
+ * bind lazily because the process is spawned inside the starter, after the
153
+ * registry admitted the job. Mirrors background.ts:67 `processSources`.
154
+ * @param {() => object|undefined} proc - the settled process accessor.
155
+ * @returns {Array<object>} one source per stream, stdout first.
156
+ */
157
+ export function processSources(proc) {
158
+ const source = (channel) => ({
159
+ channel,
160
+ read: (fromByte) => {
161
+ const live = proc()
162
+ return live === undefined || !live.observed
163
+ ? { text: '', nextOffset: fromByte, lossy: false }
164
+ : live.observed[channel].readFrom(fromByte)
165
+ },
166
+ })
167
+ return [source('stdout'), source('stderr')]
168
+ }
169
+
170
+ /**
171
+ * The ring chunks of one consuming registry read as the shell tools render a
172
+ * process read: stdout chunks in order, then every stderr chunk in one
173
+ * `[stderr]` section. Mirrors background.ts:86 `ringDelta`.
174
+ * @param {Array<{ channel: string, text: string }>} chunks
175
+ * @returns {string}
176
+ */
177
+ export function ringDelta(chunks) {
178
+ const list = Array.isArray(chunks) ? chunks : []
179
+ const out = list.filter((chunk) => chunk.channel !== 'stderr').map((chunk) => chunk.text).join('')
180
+ const err = list.filter((chunk) => chunk.channel === 'stderr').map((chunk) => chunk.text).join('')
181
+ const separator = out.length > 0 && !out.endsWith('\n') ? '\n' : ''
182
+ return out + (err.length > 0 ? `${separator}[stderr]\n${err}` : '')
183
+ }
184
+
185
+ /**
186
+ * Shape the one consuming registry read a foreground call embeds in its
187
+ * result when it stops waiting. Mirrors render.ts:99 `renderJobRead`.
188
+ */
189
+ export function renderJobRead(delta, lossy, spillPaths, sandbox, escalationModes = []) {
190
+ const notices = []
191
+ if (lossy) {
192
+ const paths = Array.isArray(spillPaths) ? spillPaths : []
193
+ notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
194
+ }
195
+ if (sandbox && sandbox.runnerFailed) {
196
+ notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
197
+ } else if (sandbox && sandbox.denied) {
198
+ notices.push(sandboxDenialMarker(sandbox.mode))
199
+ if (escalationModes.length > 0) notices.push(escalationHintMarker('command'))
200
+ }
201
+ if (notices.length === 0) return delta
202
+ return `${delta}${delta.length > 0 && !delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
203
+ }
204
+
205
+ /**
206
+ * Adapt asynchronous shell preparation after job admission without exposing a
207
+ * partial process. Mirrors background.ts:99 `processJob`.
208
+ * @param {(signal: AbortSignal) => Promise<object>} start
209
+ * @param {(proc: object) => object} outcome
210
+ * @returns {{ cancel: (reason: string) => void, done: Promise<object> }}
211
+ */
212
+ export function processJob(start, outcome) {
213
+ const controller = new AbortController()
214
+ let process
215
+ const done = (async () => {
216
+ try {
217
+ process = await start(controller.signal)
218
+ try {
219
+ if (controller.signal.aborted) process.kill()
220
+ } finally {
221
+ await process.done
222
+ }
223
+ return outcome(process)
224
+ } catch (error) {
225
+ return {
226
+ status: controller.signal.aborted && process === undefined ? 'killed' : 'failed',
227
+ detail: error instanceof Error ? error.message : String(error),
228
+ }
229
+ }
230
+ })()
231
+ return {
232
+ cancel: (reason) => {
233
+ if (controller.signal.aborted) return
234
+ controller.abort(reason)
235
+ if (process && typeof process.kill === 'function') process.kill()
236
+ },
237
+ done,
238
+ }
239
+ }