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.
- package/CHANGELOG.md +51 -0
- package/README.md +47 -33
- package/README_EN.md +44 -27
- package/cordis.patch.yml +23 -30
- package/dsh.plugin.json +2 -2
- package/package.json +10 -2
- package/src/bash-path.js +7 -4
- package/src/bash-render.js +239 -0
- package/src/bash-tool.js +589 -0
- package/src/client.js +48 -45
- package/src/compositions.js +3 -3
- package/src/index.js +28 -35
- package/src/inline-tools.js +164 -393
- package/src/shell.js +12 -9
|
@@ -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
|
+
}
|