Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@
"icon.svg"
],
"scripts": {
"test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs tests/compatibility-regressions.mjs tests/path-tool.mjs tests/native-output.mjs tests/native-executor.mjs tests/ops-adopt.mjs",
"test:preview": "node --import ./tests/host-loader.mjs --import ./tests/preview-env.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-preview.mjs tests/path-preview.mjs tests/native-output-preview.mjs tests/native-preview.mjs tests/native-integration-preview.mjs",
"test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs tests/compatibility-regressions.mjs tests/path-tool.mjs tests/native-output.mjs tests/native-executor.mjs tests/ops-adopt.mjs tests/review-core-regressions.mjs tests/review-native-regressions.mjs",
"test:preview": "node --import ./tests/host-loader.mjs --import ./tests/preview-env.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-preview.mjs tests/path-preview.mjs tests/native-output-preview.mjs tests/native-preview.mjs tests/native-integration-preview.mjs tests/review-native-preview.mjs",
"version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json"
},
"keywords": [
Expand Down
2 changes: 1 addition & 1 deletion src/bash-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ function sandboxNotes(sandbox, escalationModes) {
export function processOutcome(proc, escalationModes = []) {
const base = proc.status === 'killed'
? { status: 'killed', detail: proc.signal !== null && proc.signal !== undefined ? `signal: ${proc.signal}` : 'killed before exit' }
: { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
: { status: 'completed', detail: `exit code: ${proc.exitCode === undefined ? 'unknown' : proc.exitCode}` }
const notes = sandboxNotes(proc.sandbox, escalationModes)
return notes.length === 0 ? base : { ...base, detail: `${base.detail}; ${notes.join(' ')}` }
}
Expand Down
2 changes: 1 addition & 1 deletion src/bash-tool.js
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm
timeoutMs,
// Preserve ring/spill notices; sandbox notes belong to the final renderer.
output: renderJobRead(
ringDelta(read.chunks), read.lossy, read.job.output.spillPaths ?? [], undefined, escalationModes,
ringDelta(read.chunks), read.lossy || read.chunks.some(chunk => chunk.gapBefore), read.job.output.spillPaths ?? [], undefined, escalationModes,
),
...(sandbox === undefined ? {} : { sandbox }),
}
Expand Down
46 changes: 27 additions & 19 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2068,18 +2068,33 @@ window.__ModuleLoader__.load({
} catch (error_) { /* keep the switch usable */ }
}, []);

var bashState = useState("");
var bashDraft = bashState[0];
var setBashDraft = bashState[1];
var bashSavedState = useState(false);
var bashSaved = bashSavedState[0];
var setBashSaved = bashSavedState[1];
useEffect(function () {
var scope = props.scope;
var snap = { status: "unavailable" };
try {
var s0 = scope && typeof scope.getSnapshot === "function" ? scope.getSnapshot() : null;
if (s0 && s0.value && typeof s0.value.bashPath === "string") setBashDraft(s0.value.bashPath)
} catch (e) { /* keep empty draft */ }
}, []);
if (scope && typeof scope.getSnapshot === "function") snap = scope.getSnapshot();
} catch (error_) { /* keep unavailable */ }

var bashState = useState({ scope: scope, value: "", dirty: false, owner: {} });
var bashDraft = bashState[0].scope === scope ? bashState[0].value : "";
var setBashDraft = bashState[1];
var bashSaved = bashState[0].scope === scope && bashState[0].saved === true;
function setBashSaved(saved) {
setBashDraft(function (previous) {
// A late save only belongs to the exact draft that submitted it.
if (previous.scope !== scope || previous.owner !== bashState[0].owner || previous.saved === saved) return previous;
return Object.assign({}, previous, { saved: saved === true, dirty: saved === true ? false : previous.dirty });
});
}
var mirroredBashPath = snap.value && typeof snap.value.bashPath === "string" ? snap.value.bashPath : "";
useEffect(function () {
// Core's parent rerenders on mirror arrival. Hydrate once ready (or
// for a new scope), but never replace this scope's local edits.
setBashDraft(function (previous) {
if (previous.scope !== scope) return { scope: scope, value: snap.status === "ready" ? mirroredBashPath : "", dirty: false, owner: {} };
if (snap.status !== "ready" || previous.dirty || previous.value === mirroredBashPath) return previous;
return { scope: scope, value: mirroredBashPath, dirty: false, owner: {} };
});
}, [scope, snap.status, mirroredBashPath]);

/* v0.38.0: the card's sections are FOLDERS. All seven stay collapsed
by default — the user scans seven labelled header rows, expands the
Expand All @@ -2098,13 +2113,6 @@ window.__ModuleLoader__.load({
});
}

var scope = props.scope;

var snap = { status: "unavailable" };
try {
if (scope && typeof scope.getSnapshot === "function") snap = scope.getSnapshot();
} catch (error_) { /* keep unavailable */ }

var pageView = props.view === "page";

// Hooks run for every snapshot state; readiness only gates their work.
Expand Down Expand Up @@ -2301,7 +2309,7 @@ window.__ModuleLoader__.load({
E("span", { className: "gb-rowLabel" }, t("bash.label") + ":"),
),
E("div", { className: "gb-seg" },
E("input", { className: "gb-input", type: "text", spellCheck: false, placeholder: "C:/Program Files/Git/bin/bash.exe", value: bashDraft, onChange: function (e) { setBashDraft(e.target.value); setBashSaved(false); } }),
E("input", { className: "gb-input", type: "text", spellCheck: false, placeholder: "C:/Program Files/Git/bin/bash.exe", value: bashDraft, onChange: function (e) { setBashDraft({ scope: scope, value: e.target.value, dirty: true, owner: {} }); } }),
E("button", {
type: "button",
className: "gb-saveBtn" + (bashSaved ? " gb-saved" : ""),
Expand Down
178 changes: 124 additions & 54 deletions src/fastctx-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ export class McpStdio {
/** Spawn the server and run the MCP initialize handshake. */
async start() {
const { spawn } = await import('node:child_process')
// close() can run while the dynamic import is pending; never resurrect it.
if (this.dead) throw new Error('the FastCtx bridge was stopped')
this.child = spawn(this.file, this.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
this.child.stdout.setEncoding('utf8')
this.child.stderr.setEncoding('utf8')
Expand Down Expand Up @@ -323,91 +325,159 @@ const FASTCTX_SANDBOX_NOTE = 'FastCtx reads the real filesystem with no workspac
* - echo(text) -> result dialect echo, or undefined (off)
* - log(message) -> the plugin's tagged logger
*
* The surface publishes at the FIRST agent creation and re-decides on every
* later one: dsh-ops appearing (ops_* tools in the face) withdraws gb_* for
* the rest of the process — duplicates would only teach the model two names
* for one job.
* The surface publishes at the first agent creation or live switch update.
* User OFF/ON is reversible; dsh-ops appearing (ops_* tools in the face)
* independently withdraws gb_* for the rest of the process. Every call also
* checks the live switch and its publication generation, including old SDKs.
*/
export function mountFastctx(ctx, deps) {
const { platform, enabled, createRequireFrom, tools, msysToWin, echo, log } = deps
if (platform !== 'win32') return
let disposer = undefined
let client = undefined
let decided = false
let unavailable = false
let withdrawnByOps = false
let switchOn
let generation = 0
let closed = false
let publishing
let connecting
const starting = new Set()
const pending = new Set()
const agents = new Set()

const track = (promise) => {
pending.add(promise)
promise.then(() => pending.delete(promise), () => pending.delete(promise))
return promise
}

const unpublish = (reason) => {
if (disposer) {
try { disposer() } catch { /* already gone */ }
const dispose = disposer
disposer = undefined
try { dispose() } catch { /* already gone */ }
}
if (reason) log(reason)
}

const callWithReconnect = async (rawName, args) => {
if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
const stopConnections = () => {
const owned = new Set([...starting, client].filter(Boolean))
client = undefined
// Calling close immediately rejects pending requests and kills the child;
// track completion as well so disposal owns even superseded generations.
track(Promise.allSettled([...owned].map((connection) => connection.close())))
}

const live = (agent) => {
if (closed) return false
const next = !!enabled()
if (switchOn !== next) {
switchOn = next
generation++
unavailable = false
if (!next) {
unpublish('FastCtx bridge disabled by the fastctxTools switch')
stopConnections()
}
}
if (!withdrawnByOps) {
const registry = tools(ctx)
const faces = new Set(agent ? [agent, ...agents] : agents.size ? agents : [undefined])
for (const face of faces) {
const names = (registry?.schemas?.(face) ?? []).map((schema) => schema?.name)
// Any ops_* name means dsh-ops owns the file surface. Matching the
// prefix (rather than only the listed markers) covers the 0.4.0
// progressive residents AND the names the marker list does not carry
// yet (ops_batch / ops_glob / ops_replace / ops_run): a later caller
// can hand the face a name that was never resident in face 1.
if (names.some((name) => typeof name === 'string'
&& (name.startsWith('ops_') || OPS_PRESENCE_MARKERS.includes(name)))) {
withdrawnByOps = true
generation++
unpublish('dsh-ops detected (ops_* tools present): the gb_* surface stands down')
stopConnections()
break
}
}
}
return switchOn && !withdrawnByOps
}

const assertLive = (owner, agent) => {
if (!live(agent) || owner !== generation || !disposer) throw new Error('the FastCtx bridge was stopped')
}

const callWithReconnect = async (rawName, args, owner, agent) => {
assertLive(owner, agent)
if (client === undefined || client.isDead) {
if (!connecting) {
connecting = (async () => {
if (!connecting || connecting.owner !== owner) {
const task = { owner }
task.promise = track((async () => {
const { file } = resolveFastctx(ctx, createRequireFrom)
if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry')
const fresh = new McpStdio({ file, log })
starting.add(fresh)
try {
await fresh.start()
if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
assertLive(owner, agent)
client = fresh
} catch (error) {
await fresh.close()
throw error
} finally {
starting.delete(fresh)
}
})().finally(() => { connecting = undefined })
})().finally(() => { if (connecting === task) connecting = undefined }))
connecting = task
}
await connecting
await connecting.promise
}
if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
assertLive(owner, agent)
// Reconnect only BEFORE dispatch. Never replay a failed tools/call: it may
// already have written files even when its response is an ordinary error.
return client.callTool(rawName, args)
}

const publishOnce = async () => {
if (closed || disposer || decided && withdrawnByOps) return
const publishOnce = async (owner) => {
if (!live() || owner !== generation || disposer || unavailable) return
const registry = tools(ctx)
if (!registry || typeof registry.register !== 'function') return
const { file, source } = resolveFastctx(ctx, createRequireFrom)
if (file === '') {
log('FastCtx payload not found (probed ' + PAYLOAD_PACKAGES.map((p) => p.pkg).join(', ') + '); gb_* tools stay unpublished')
decided = true
withdrawnByOps = true // nothing to publish either way; stop re-deciding
unavailable = true
return
}
const fresh = new McpStdio({ file, log })
starting.add(fresh)
let toolList
try {
await fresh.start()
if (!live() || owner !== generation) return
toolList = await fresh.listTools()
} catch (error) {
try { await fresh.close() } catch { /* best effort */ }
log(`FastCtx server start failed (${source}): ${error?.message ?? error}; gb_* tools stay unpublished`)
decided = true
withdrawnByOps = true
if (live() && owner === generation) {
log(`FastCtx server start failed (${source}): ${error?.message ?? error}; gb_* tools stay unpublished`)
unavailable = true
}
return
} finally {
try { await fresh.close() } catch { /* best effort */ }
starting.delete(fresh)
}
await fresh.close()
if (closed || withdrawnByOps) return
if (!live() || owner !== generation) return
// Discovery never overwrites the independently owned lazy call client.
const wanted = toolList.filter((tool) => FASTCTX_FILE_TOOLS.includes(String(tool?.name)))
const registered = []
const registeredNames = []
const release = () => {
for (const dispose of registered) {
try { dispose() } catch (error) { log(`FastCtx unregister failed: ${error?.message ?? error}`) }
}
}
for (const tool of wanted) {
if (!live() || owner !== generation) break
const rawName = String(tool.name)
const schema = tool.inputSchema && typeof tool.inputSchema === 'object' ? tool.inputSchema : { type: 'object', properties: {} }
// Ignore-rule transparency (v0.38.5, the field smoke report): the three
Expand Down Expand Up @@ -438,14 +508,15 @@ export function mountFastctx(ctx, deps) {
render: (_args, value) => renderTextContent(value),
},
async execute(args, execution) {
assertLive(owner, execution?.agent)
// Preserve v0.39.1 / #21: glob accepts real string arrays,
// while stringified arrays and malformed patterns fail loudly.
if (args !== null && typeof args === 'object' && 'pattern' in args) {
const invalid = validateFastctxPattern(rawName, args.pattern)
if (invalid) throw new Error(invalid)
}
const translated = translateFastctxArgs(args, msysToWin ? (path) => msysToWin(path, execution) : undefined)
const result = await callWithReconnect(rawName, translated)
const result = await callWithReconnect(rawName, translated, owner, execution?.agent)
const text = renderFastctxResult(rawName, result, echo ? (value) => echo(value, execution) : undefined)
// The host treats a returned value as success; MCP failures must
// throw, just as its official MCP client does.
Expand All @@ -461,52 +532,51 @@ export function mountFastctx(ctx, deps) {
log(`${gbName(rawName)} registration failed: ${error?.message ?? error}`)
}
}
if (!live() || owner !== generation) {
release()
return
}
if (registered.length === 0) {
log('FastCtx published none of the known file tools; nothing registered')
decided = true
withdrawnByOps = true
unavailable = true
return
}
disposer = () => { for (const dispose of registered) dispose() }
disposer = release
log(`FastCtx bridge active (${source}): ${registeredNames.join(', ')}`)
}

const publish = () => {
if (!publishing) publishing = publishOnce().finally(() => { publishing = undefined })
return publishing
if (!publishing || publishing.owner !== generation) {
const task = { owner: generation }
task.promise = track(publishOnce(task.owner).finally(() => { if (publishing === task) publishing = undefined }))
publishing = task
}
return publishing.promise
}

const reconcile = (agent) => {
if (closed) return
if (!enabled()) { unpublish('FastCtx bridge disabled by the fastctxTools switch'); decided = true; withdrawnByOps = true; return }
if (decided && withdrawnByOps) return
let names = []
try {
names = (tools(ctx)?.schemas?.(agent) ?? []).map((schema) => schema.name).filter((name) => typeof name === 'string')
} catch { /* a broken face reads as empty */ }
if (names.some((name) => OPS_PRESENCE_MARKERS.includes(name))) {
if (!withdrawnByOps) {
withdrawnByOps = true
decided = true
unpublish('dsh-ops detected (ops_* tools present): the gb_* surface stands down')
}
return
if (!live(agent)) return
void publish().catch((error) => log(`FastCtx bridge mount failed: ${error?.message ?? error}`))
} catch (error) {
log(`FastCtx bridge reconcile failed: ${error?.message ?? error}`)
}
void publish().catch((error) => log(`FastCtx bridge mount failed: ${error?.message ?? error}`))
}

try {
ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
} catch (error) {
log(`FastCtx bridge wiring failed: ${error?.message ?? error}`)
return
}
// Install disposal before listeners: partial wiring still owns every child.
ctx.effect(() => async () => {
closed = true
generation++
agents.clear()
unpublish()
const owned = new Set([...starting, client].filter(Boolean))
await Promise.allSettled([...owned].map((connection) => connection.close()))
await Promise.allSettled([publishing, connecting].filter(Boolean))
client = undefined
stopConnections()
await Promise.allSettled([...pending])
}, 'dsh-gitbash-shell: FastCtx bridge')
try {
ctx.on('agent/created', ({ agent } = {}) => { if (!closed && agent) agents.add(agent); reconcile(agent) })
ctx.on('agent/disposed', ({ agent } = {}) => { agents.delete(agent) })
ctx.on('loader/volatile-update', () => { reconcile() })
} catch (error) {
log(`FastCtx bridge wiring failed: ${error?.message ?? error}`)
}
}
Loading
Loading