dsh-gitbash-shell 0.34.3 → 0.35.1
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 +21 -0
- package/README.md +38 -15
- package/README_EN.md +29 -8
- package/dsh.plugin.json +2 -2
- package/package.json +4 -1
- package/src/client.js +170 -0
- package/src/fastctx-bridge.js +378 -0
- package/src/index.js +229 -8
- package/src/inline-tools.js +434 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-gitbash-shell/fastctx-bridge — the `gb_*` file tools over FastCtx (v0.35.0).
|
|
3
|
+
*
|
|
4
|
+
* FastCtx (github.com/yc-duan/fastctx, Apache-2.0) is a local Rust MCP server
|
|
5
|
+
* with context-efficient file tools: batched ranged reads with an output
|
|
6
|
+
* budget, grep with truncation markers, encoding detection that reports
|
|
7
|
+
* candidate encodings instead of guessing. On a Windows host those four
|
|
8
|
+
* abilities are exactly what the official tools lack, so this bridge publishes
|
|
9
|
+
* them as ADDITIONS (`gb_inspect_local_file`, `gb_grep`, `gb_glob`,
|
|
10
|
+
* `gb_replace`) — the official read/grep/glob/edit tools and their file
|
|
11
|
+
* sandbox stay untouched and remain the default recommendation.
|
|
12
|
+
*
|
|
13
|
+
* TRANSPORT. A minimal MCP stdio client (newline-delimited JSON-RPC over the
|
|
14
|
+
* child's stdin/stdout, ~120 lines). The official @deepseek-ai/dsh-mcp-client
|
|
15
|
+
* exists but is a statically-configured plugin ROW: its `command` must be a
|
|
16
|
+
* literal (no resolver runs in the `!!js` sandbox), its names are locked to
|
|
17
|
+
* `mcp__<server>__<raw>`, and it has no per-server tool allowlist — while this
|
|
18
|
+
* bridge needs a payload path resolved from the profile layout, a closed tool
|
|
19
|
+
* set (FastCtx's shell/background tools must never surface), a dialect
|
|
20
|
+
* translation on every path argument, and a stand-down when dsh-ops is
|
|
21
|
+
* present. When the host one day exposes a runtime bridge API, this module is
|
|
22
|
+
* the first thing to retire — tracked in AGENTS.md.
|
|
23
|
+
*
|
|
24
|
+
* THE PAYLOAD. FastCtx itself is never bundled. The bridge probes, in order,
|
|
25
|
+
* the upstream npm payload (`@fastctx/win32-x64`) and the dsh-ops fork payload
|
|
26
|
+
* (`@dsh-ops/fastctx-win32-x64`, the one this plugin declares as an optional
|
|
27
|
+
* dependency, pinned with a recorded provenance). Neither present -> the
|
|
28
|
+
* bridge mounts nothing and says so once. The server is started WITHOUT
|
|
29
|
+
* `--enable-shell`: FastCtx's command/job tools do not exist in this
|
|
30
|
+
* deployment, and `gb_*` stays a pure file-tool surface.
|
|
31
|
+
*
|
|
32
|
+
* NO SANDBOX, STATED. FastCtx reads the real filesystem with no workspace
|
|
33
|
+
* confinement; that is FastCtx's own contract, so every `gb_` description
|
|
34
|
+
* says the file tools remain the sandboxed default and `gb_replace` warns
|
|
35
|
+
* about its cross-file reach.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The only FastCtx tools this bridge ever publishes. */
|
|
39
|
+
export const FASTCTX_FILE_TOOLS = Object.freeze(['inspect_local_file', 'grep', 'glob', 'replace'])
|
|
40
|
+
|
|
41
|
+
/** Public names follow the bridge prefix, never the server's own. */
|
|
42
|
+
export const GB_PREFIX = 'gb_'
|
|
43
|
+
export const gbName = (raw) => GB_PREFIX + raw
|
|
44
|
+
|
|
45
|
+
/** Payload packages probed, in order: upstream first, the pinned fork second. */
|
|
46
|
+
export const PAYLOAD_PACKAGES = Object.freeze([
|
|
47
|
+
{ pkg: '@fastctx/win32-x64', exe: 'bin/fastctx.exe' },
|
|
48
|
+
{ pkg: '@dsh-ops/fastctx-win32-x64', exe: 'bin/fastctx.exe' },
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the fastctx executable from the profile's resolution base. Pure in
|
|
53
|
+
* its decisions, I/O through the injected `createRequireFrom`.
|
|
54
|
+
* @returns {{ file: string, source: string } | { file: '', source: '' }}
|
|
55
|
+
*/
|
|
56
|
+
export function resolveFastctx(ctx, createRequireFrom) {
|
|
57
|
+
let req
|
|
58
|
+
try {
|
|
59
|
+
req = createRequireFrom(ctx?.baseUrl ?? import.meta.url)
|
|
60
|
+
} catch {
|
|
61
|
+
return { file: '', source: '' }
|
|
62
|
+
}
|
|
63
|
+
for (const { pkg, exe } of PAYLOAD_PACKAGES) {
|
|
64
|
+
try {
|
|
65
|
+
const root = req.resolve(`${pkg}/package.json`)
|
|
66
|
+
const { join, dirname } = req('node:path')
|
|
67
|
+
const { existsSync } = req('node:fs')
|
|
68
|
+
const file = join(dirname(root), exe)
|
|
69
|
+
if (existsSync(file)) return { file, source: pkg }
|
|
70
|
+
} catch { /* probe the next payload */ }
|
|
71
|
+
}
|
|
72
|
+
return { file: '', source: '' }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One MCP stdio connection. Requests carry incrementing ids; responses are
|
|
77
|
+
* matched by id; the server's stderr is captured for the error report. Every
|
|
78
|
+
* call has a deadline. NOT thread-safe — the harness serializes tool calls.
|
|
79
|
+
*/
|
|
80
|
+
export class McpStdio {
|
|
81
|
+
constructor({ file, args = [], timeoutMs = 60_000, log = () => {} }) {
|
|
82
|
+
this.file = file
|
|
83
|
+
this.args = args
|
|
84
|
+
this.timeoutMs = timeoutMs
|
|
85
|
+
this.log = log
|
|
86
|
+
this.child = undefined
|
|
87
|
+
this.nextId = 1
|
|
88
|
+
this.pending = new Map()
|
|
89
|
+
this.buffer = ''
|
|
90
|
+
this.stderrTail = ''
|
|
91
|
+
this.dead = false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Spawn the server and run the MCP initialize handshake. */
|
|
95
|
+
async start() {
|
|
96
|
+
const { spawn } = await import('node:child_process')
|
|
97
|
+
this.child = spawn(this.file, this.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
|
98
|
+
this.child.stdout.setEncoding('utf8')
|
|
99
|
+
this.child.stderr.setEncoding('utf8')
|
|
100
|
+
this.child.stdout.on('data', (chunk) => this.#onChunk(chunk))
|
|
101
|
+
this.child.stderr.on('data', (chunk) => {
|
|
102
|
+
this.stderrTail = (this.stderrTail + chunk).slice(-2000)
|
|
103
|
+
})
|
|
104
|
+
this.child.on('exit', (code) => {
|
|
105
|
+
this.dead = true
|
|
106
|
+
for (const [, { reject }] of [...this.pending]) {
|
|
107
|
+
reject(new Error(`the FastCtx server exited (code ${code ?? 'null'})`))
|
|
108
|
+
}
|
|
109
|
+
this.pending.clear()
|
|
110
|
+
})
|
|
111
|
+
await this.#request('initialize', {
|
|
112
|
+
protocolVersion: '2024-11-05',
|
|
113
|
+
capabilities: {},
|
|
114
|
+
clientInfo: { name: 'dsh-gitbash-shell', version: '0.35.0' },
|
|
115
|
+
})
|
|
116
|
+
this.#notify('notifications/initialized')
|
|
117
|
+
return this
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** List the server's tools: [{ name, description, inputSchema }]. */
|
|
121
|
+
async listTools() {
|
|
122
|
+
const result = await this.#request('tools/list', {})
|
|
123
|
+
return Array.isArray(result?.tools) ? result.tools : []
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Call one tool; returns the raw MCP result ({ content, isError? }). */
|
|
127
|
+
async callTool(rawName, args) {
|
|
128
|
+
return this.#request('tools/call', { name: rawName, arguments: args ?? {} })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether the transport is gone (a later call re-launches via the mount). */
|
|
132
|
+
get isDead() { return this.dead }
|
|
133
|
+
|
|
134
|
+
async close() {
|
|
135
|
+
this.dead = true
|
|
136
|
+
const child = this.child
|
|
137
|
+
this.child = undefined
|
|
138
|
+
for (const [, { reject }] of [...this.pending]) {
|
|
139
|
+
reject(new Error('the FastCtx bridge was stopped'))
|
|
140
|
+
}
|
|
141
|
+
this.pending.clear()
|
|
142
|
+
if (child && child.exitCode === null && !child.killed) {
|
|
143
|
+
try { child.kill() } catch { /* already gone */ }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#onChunk(chunk) {
|
|
148
|
+
this.buffer += chunk
|
|
149
|
+
let at = this.buffer.indexOf('\n')
|
|
150
|
+
while (at !== -1) {
|
|
151
|
+
const line = this.buffer.slice(0, at).trim()
|
|
152
|
+
this.buffer = this.buffer.slice(at + 1)
|
|
153
|
+
if (line !== '') this.#onLine(line)
|
|
154
|
+
at = this.buffer.indexOf('\n')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#onLine(line) {
|
|
159
|
+
let message
|
|
160
|
+
try { message = JSON.parse(line) } catch { return /* not JSON: ignore */ }
|
|
161
|
+
const id = message?.id
|
|
162
|
+
if (id !== undefined && this.pending.has(id)) {
|
|
163
|
+
const { resolve, reject, timer } = this.pending.get(id)
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
this.pending.delete(id)
|
|
166
|
+
if (message.error) reject(new Error(String(message.error.message ?? JSON.stringify(message.error))))
|
|
167
|
+
else resolve(message.result)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#send(frame) {
|
|
172
|
+
if (!this.child || this.dead) throw new Error('the FastCtx server is not running')
|
|
173
|
+
this.child.stdin.write(JSON.stringify(frame) + '\n')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#notify(method, params) {
|
|
177
|
+
this.#send({ jsonrpc: '2.0', method, ...params !== undefined ? { params } : {} })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#request(method, params) {
|
|
181
|
+
if (!this.child || this.dead) return Promise.reject(new Error('the FastCtx server is not running'))
|
|
182
|
+
const id = this.nextId++
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
const timer = setTimeout(() => {
|
|
185
|
+
this.pending.delete(id)
|
|
186
|
+
reject(new Error(`${method} timed out after ${this.timeoutMs}ms`))
|
|
187
|
+
}, this.timeoutMs)
|
|
188
|
+
timer.unref?.()
|
|
189
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
190
|
+
try {
|
|
191
|
+
this.#send({ jsonrpc: '2.0', id, method, ...params !== undefined ? { params } : {} })
|
|
192
|
+
} catch (error) {
|
|
193
|
+
clearTimeout(timer)
|
|
194
|
+
this.pending.delete(id)
|
|
195
|
+
reject(error)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Translate the path-bearing fields of one gb_* argument object, MSYS ->
|
|
203
|
+
* Windows, using the SAME mount table the official file tools get. Handles
|
|
204
|
+
* the fastctx shapes: `path`, `file_path`, `files[].path`, and an absolute
|
|
205
|
+
* glob `pattern`. Pure.
|
|
206
|
+
*/
|
|
207
|
+
export function translateFastctxArgs(args, msysToWin) {
|
|
208
|
+
if (!msysToWin || !args || typeof args !== 'object') return args
|
|
209
|
+
const translate = (value) => (typeof value === 'string' ? msysToWin(value) : value)
|
|
210
|
+
const out = { ...args }
|
|
211
|
+
for (const field of ['path', 'file_path', 'workdir']) {
|
|
212
|
+
if (typeof out[field] === 'string') out[field] = translate(out[field])
|
|
213
|
+
}
|
|
214
|
+
if (Array.isArray(out.files)) {
|
|
215
|
+
out.files = out.files.map((entry) => (entry && typeof entry === 'object' && typeof entry.path === 'string'
|
|
216
|
+
? { ...entry, path: translate(entry.path) }
|
|
217
|
+
: entry))
|
|
218
|
+
}
|
|
219
|
+
// A glob pattern that is absolute (starts with a drive root or /) is
|
|
220
|
+
// translated whole; a relative pattern must stay relative.
|
|
221
|
+
if (typeof out.pattern === 'string' && /^[/~]/.test(out.pattern)) {
|
|
222
|
+
out.pattern = translate(out.pattern)
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Render one MCP result as the text the model reads, with the dialect echo
|
|
229
|
+
* applied to the text blocks. Mirrors the host's own MCP projection closely
|
|
230
|
+
* enough for these four tools: text blocks joined, non-text blocks named.
|
|
231
|
+
* Pure.
|
|
232
|
+
*/
|
|
233
|
+
export function renderFastctxResult(rawName, result, echo) {
|
|
234
|
+
const content = Array.isArray(result?.content) ? result.content : []
|
|
235
|
+
const parts = []
|
|
236
|
+
for (const block of content) {
|
|
237
|
+
if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
238
|
+
else parts.push(`[unsupported MCP content block: ${String(block?.type ?? 'unknown')}]`)
|
|
239
|
+
}
|
|
240
|
+
let text = parts.join('\n')
|
|
241
|
+
if (text === '') text = `(${gbName(rawName)} returned no content)`
|
|
242
|
+
if (echo) text = echo(text)
|
|
243
|
+
const header = result?.isError === true ? `${gbName(rawName)} failed: ` : ''
|
|
244
|
+
return header + text
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The sandbox honesty line appended to every gb_* description. */
|
|
248
|
+
const FASTCTX_SANDBOX_NOTE = 'FastCtx reads the real filesystem with no workspace confinement: for sandboxed work prefer the official read/grep/glob/edit tools, which stay the default.'
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Mount the bridge. `deps`:
|
|
252
|
+
* - platform -> process.platform (win32 only mounts)
|
|
253
|
+
* - enabled() -> the row switch (fastctxTools)
|
|
254
|
+
* - createRequireFrom(ctx) -> the profile resolution seam
|
|
255
|
+
* - tools(ctx) -> the host tool registry (or undefined)
|
|
256
|
+
* - msysToWin(path) -> argument dialect translation
|
|
257
|
+
* - echo(text) -> result dialect echo, or undefined (off)
|
|
258
|
+
* - log(message) -> the plugin's tagged logger
|
|
259
|
+
*
|
|
260
|
+
* The surface publishes at the FIRST agent creation and re-decides on every
|
|
261
|
+
* later one: dsh-ops appearing (ops_* tools in the face) withdraws gb_* for
|
|
262
|
+
* the rest of the process — duplicates would only teach the model two names
|
|
263
|
+
* for one job.
|
|
264
|
+
*/
|
|
265
|
+
export function mountFastctx(ctx, deps) {
|
|
266
|
+
const { platform, enabled, createRequireFrom, tools, msysToWin, echo, log } = deps
|
|
267
|
+
if (platform !== 'win32') return
|
|
268
|
+
let disposer = undefined
|
|
269
|
+
let client = undefined
|
|
270
|
+
let decided = false
|
|
271
|
+
let withdrawnByOps = false
|
|
272
|
+
|
|
273
|
+
const unpublish = (reason) => {
|
|
274
|
+
if (disposer) {
|
|
275
|
+
try { disposer() } catch { /* already gone */ }
|
|
276
|
+
disposer = undefined
|
|
277
|
+
}
|
|
278
|
+
if (reason) log(reason)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const callWithReconnect = async (rawName, args) => {
|
|
282
|
+
if (client === undefined || client.isDead) {
|
|
283
|
+
const { file } = resolveFastctx(ctx, createRequireFrom)
|
|
284
|
+
if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry')
|
|
285
|
+
client = await new McpStdio({ file, log }).start()
|
|
286
|
+
}
|
|
287
|
+
return client.callTool(rawName, args)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const publish = async () => {
|
|
291
|
+
if (disposer || decided && withdrawnByOps) return
|
|
292
|
+
const registry = tools(ctx)
|
|
293
|
+
if (!registry || typeof registry.register !== 'function') return
|
|
294
|
+
const { file, source } = resolveFastctx(ctx, createRequireFrom)
|
|
295
|
+
if (file === '') {
|
|
296
|
+
log('FastCtx payload not found (probed ' + PAYLOAD_PACKAGES.map((p) => p.pkg).join(', ') + '); gb_* tools stay unpublished')
|
|
297
|
+
decided = true
|
|
298
|
+
withdrawnByOps = true // nothing to publish either way; stop re-deciding
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
const fresh = new McpStdio({ file, log })
|
|
302
|
+
let toolList
|
|
303
|
+
try {
|
|
304
|
+
await fresh.start()
|
|
305
|
+
toolList = await fresh.listTools()
|
|
306
|
+
} catch (error) {
|
|
307
|
+
try { await fresh.close() } catch { /* best effort */ }
|
|
308
|
+
log(`FastCtx server start failed (${source}): ${error?.message ?? error}; gb_* tools stay unpublished`)
|
|
309
|
+
decided = true
|
|
310
|
+
withdrawnByOps = true
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
await fresh.close()
|
|
314
|
+
client = undefined // the first call re-launches lazily; boot stays cheap
|
|
315
|
+
const wanted = toolList.filter((tool) => FASTCTX_FILE_TOOLS.includes(String(tool?.name)))
|
|
316
|
+
const registered = []
|
|
317
|
+
const registeredNames = []
|
|
318
|
+
for (const tool of wanted) {
|
|
319
|
+
const rawName = String(tool.name)
|
|
320
|
+
const schema = tool.inputSchema && typeof tool.inputSchema === 'object' ? tool.inputSchema : { type: 'object', properties: {} }
|
|
321
|
+
try {
|
|
322
|
+
const dispose = registry.register({
|
|
323
|
+
name: gbName(rawName),
|
|
324
|
+
description: `${String(tool.description ?? rawName)}\n${FASTCTX_SANDBOX_NOTE}`,
|
|
325
|
+
parameters: schema,
|
|
326
|
+
async execute(args) {
|
|
327
|
+
const translated = translateFastctxArgs(args, msysToWin)
|
|
328
|
+
const result = await callWithReconnect(rawName, translated)
|
|
329
|
+
return { content: [{ type: 'text', text: renderFastctxResult(rawName, result, echo) }] }
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
if (typeof dispose === 'function') {
|
|
333
|
+
registered.push(dispose)
|
|
334
|
+
registeredNames.push(gbName(rawName))
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
log(`${gbName(rawName)} registration failed: ${error?.message ?? error}`)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (registered.length === 0) {
|
|
341
|
+
log('FastCtx published none of the known file tools; nothing registered')
|
|
342
|
+
decided = true
|
|
343
|
+
withdrawnByOps = true
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
disposer = () => { for (const dispose of registered) dispose() }
|
|
347
|
+
log(`FastCtx bridge active (${source}): ${registeredNames.join(', ')}`)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const reconcile = (agent) => {
|
|
351
|
+
if (!enabled()) { unpublish('FastCtx bridge disabled by the fastctxTools switch'); decided = true; withdrawnByOps = true; return }
|
|
352
|
+
if (decided && withdrawnByOps) return
|
|
353
|
+
let names = []
|
|
354
|
+
try {
|
|
355
|
+
names = (tools(ctx)?.schemas?.(agent) ?? []).map((schema) => schema.name).filter((name) => typeof name === 'string')
|
|
356
|
+
} catch { /* a broken face reads as empty */ }
|
|
357
|
+
if (names.some((name) => name === 'ops_inspect_local_file' || name === 'ops_grep' || name === 'ops_bash')) {
|
|
358
|
+
if (!withdrawnByOps) {
|
|
359
|
+
withdrawnByOps = true
|
|
360
|
+
decided = true
|
|
361
|
+
unpublish('dsh-ops detected (ops_* tools present): the gb_* surface stands down')
|
|
362
|
+
}
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
void publish().catch((error) => log(`FastCtx bridge mount failed: ${error?.message ?? error}`))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
|
|
370
|
+
} catch (error) {
|
|
371
|
+
log(`FastCtx bridge wiring failed: ${error?.message ?? error}`)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
ctx.effect(() => () => {
|
|
375
|
+
unpublish()
|
|
376
|
+
if (client) { void client.close().catch(() => {}) }
|
|
377
|
+
}, 'dsh-gitbash-shell: FastCtx bridge')
|
|
378
|
+
}
|