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.
@@ -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
+ }