dsh-ops 0.0.0-stage → 0.2.2
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 +202 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +435 -0
- package/README.en.md +126 -0
- package/README.md +115 -2
- package/README.zh.md +116 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/release-0.2.1.md +72 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +998 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
package/lib/tools.js
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FastCtx tool surface: this plugin's own long-lived MCP stdio connection
|
|
3
|
+
* to the vendored FastCtx server, and the `ops_<rawName>` tool definitions it
|
|
4
|
+
* publishes into the harness tool registry.
|
|
5
|
+
*
|
|
6
|
+
* The boundary held here is deliberate, because breaking it already shipped
|
|
7
|
+
* once: **the plugin registers its own tools through its own context and never
|
|
8
|
+
* rewrites the shared tool registry.** Replacing `ToolRuntime.register` made
|
|
9
|
+
* every *foreign* registration look like this plugin's own — a Cordis service
|
|
10
|
+
* property read returns a wrapper bound to the reading context, so the
|
|
11
|
+
* patched call attributed the caller's write to this plugin — and the host's
|
|
12
|
+
* second registration of a name such as `subagent` then threw "already
|
|
13
|
+
* registered", failing every new session. See the plugin's `AGENTS.md`,
|
|
14
|
+
* "上游兼容 (upstream compatibility)".
|
|
15
|
+
*
|
|
16
|
+
* Load-time semantics are the caller's: an unavailable server is fatal only
|
|
17
|
+
* when the deployment says `required: true`.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-ops/tools
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { McpStdioClient } from './handshake.js'
|
|
23
|
+
import { FILE_TOOLS, SHELL_TOOLS, publicToolName } from './policy.js'
|
|
24
|
+
import { projectTool } from './presentation.js'
|
|
25
|
+
import { cleanBackground, jobEntries, startedJob } from './jobs.js'
|
|
26
|
+
|
|
27
|
+
/** Deadline for one connection attempt: spawn, initialize, and `tools/list`. */
|
|
28
|
+
const CONNECT_TIMEOUT_MS = 60_000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reconnect policy. One outage shares one attempt budget: `maxAttempts`
|
|
32
|
+
* consecutive failed attempts, delays doubling from `initialDelayMs` up to
|
|
33
|
+
* `maxDelayMs`. A connection that stayed up at least `maxDelayMs` closes the
|
|
34
|
+
* outage, so the next disconnect starts a fresh budget while a crash-looping
|
|
35
|
+
* server — even one whose connects briefly succeed — still exhausts the cap
|
|
36
|
+
* instead of restarting forever.
|
|
37
|
+
*/
|
|
38
|
+
export const RECONNECT = Object.freeze({
|
|
39
|
+
initialDelayMs: 500,
|
|
40
|
+
maxDelayMs: 30_000,
|
|
41
|
+
maxAttempts: 10,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** Credential-shaped environment names the harness never forwards to a child. */
|
|
45
|
+
const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
|
|
46
|
+
|
|
47
|
+
/** The harness's own fact prefix, never forwarded to a child implicitly. */
|
|
48
|
+
const DSH_ENV_PREFIX = 'DSH_'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The canonical value schema of one published tool: the MCP content array,
|
|
52
|
+
* plus the server's structured result when it sends one. `structuredContent`
|
|
53
|
+
* is declared but not required, so a result without one is still canonical.
|
|
54
|
+
*/
|
|
55
|
+
const OUTPUT_SCHEMA = Object.freeze({
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
content: { type: 'array', items: {} },
|
|
59
|
+
structuredContent: {},
|
|
60
|
+
},
|
|
61
|
+
required: ['content'],
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** Raised internally when `stop()` wins the race against an in-flight connect. */
|
|
66
|
+
class StoppedError extends Error {
|
|
67
|
+
constructor() {
|
|
68
|
+
super('the FastCtx tool surface was stopped')
|
|
69
|
+
this.name = 'StoppedError'
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Describe one thrown value for a report line.
|
|
75
|
+
* @param {unknown} error - the thrown value.
|
|
76
|
+
* @returns {string} the message.
|
|
77
|
+
*/
|
|
78
|
+
function messageOf(error) {
|
|
79
|
+
return String(/** @type {{message?: unknown}} */ (error)?.message ?? error)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The environment one FastCtx child starts from.
|
|
84
|
+
*
|
|
85
|
+
* The harness never forwards credential-shaped names or its own `DSH_*` facts
|
|
86
|
+
* to a child, and the MCP bridge this plugin used to mount applied that same
|
|
87
|
+
* scrub. The plugin spawns the server itself now, so it keeps the guarantee:
|
|
88
|
+
* `PATH`, `HOME`, locale, and proxy variables survive so child tooling runs
|
|
89
|
+
* normally, while credentials are never inherited implicitly (`gh` and `git`
|
|
90
|
+
* keep reading their own configuration files).
|
|
91
|
+
* @param {NodeJS.ProcessEnv} [parent] - the environment to scrub.
|
|
92
|
+
* @returns {NodeJS.ProcessEnv} a fresh environment object safe to hand to a spawn.
|
|
93
|
+
*/
|
|
94
|
+
export function childEnv(parent = process.env) {
|
|
95
|
+
const env = {}
|
|
96
|
+
for (const [key, value] of Object.entries(parent)) {
|
|
97
|
+
if (value === undefined) continue
|
|
98
|
+
if (SENSITIVE_ENV_PATTERN.test(key)) continue
|
|
99
|
+
if (key.toUpperCase().startsWith(DSH_ENV_PREFIX)) continue
|
|
100
|
+
env[key] = value
|
|
101
|
+
}
|
|
102
|
+
return env
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The FastCtx command line this deployment wants: the shell tools are opt-in.
|
|
107
|
+
* @param {boolean} enableShellTools - whether to publish FastCtx's run/job tools.
|
|
108
|
+
* @returns {string[]} the server arguments.
|
|
109
|
+
*/
|
|
110
|
+
export function serverArgs(enableShellTools) {
|
|
111
|
+
return enableShellTools ? ['serve', '--enable-shell'] : ['serve']
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Project one MCP content array into the text the model reads.
|
|
116
|
+
*
|
|
117
|
+
* Text blocks are joined verbatim. Blocks this plugin cannot present as model
|
|
118
|
+
* context (images, audio, embedded resources) become one explicit placeholder
|
|
119
|
+
* rather than silence, and the raw MCP content stays in the canonical value for
|
|
120
|
+
* programmatic callers.
|
|
121
|
+
* @param {unknown[]} content - the MCP content array.
|
|
122
|
+
* @param {string} rawName - the server's own tool name, for the empty case.
|
|
123
|
+
* @returns {string} the rendered text.
|
|
124
|
+
*/
|
|
125
|
+
export function renderContent(content, rawName) {
|
|
126
|
+
const parts = []
|
|
127
|
+
for (const value of content) {
|
|
128
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
129
|
+
parts.push('[unsupported MCP content block: expected an object]')
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
const block = /** @type {Record<string, any>} */ (value)
|
|
133
|
+
switch (block.type) {
|
|
134
|
+
case 'text':
|
|
135
|
+
if (typeof block.text === 'string') parts.push(block.text)
|
|
136
|
+
break
|
|
137
|
+
case 'resource_link':
|
|
138
|
+
parts.push(typeof block.name === 'string' && typeof block.uri === 'string'
|
|
139
|
+
? `Resource link: ${block.name} (${block.uri})`
|
|
140
|
+
: '[resource link unavailable: the MCP block is missing its name or URI]')
|
|
141
|
+
break
|
|
142
|
+
case 'image':
|
|
143
|
+
case 'audio':
|
|
144
|
+
case 'resource':
|
|
145
|
+
parts.push(`[${block.type} content is not shown to the model; the raw MCP result remains `
|
|
146
|
+
+ 'available to programmatic callers]')
|
|
147
|
+
break
|
|
148
|
+
default:
|
|
149
|
+
parts.push(`[unsupported MCP content type: ${String(block.type)}]`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const text = parts.join('\n')
|
|
153
|
+
return text === '' ? `(${publicToolName(rawName)} returned no model-visible content)` : text
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Map one MCP `tools/call` result onto the harness tool-result contract.
|
|
158
|
+
*
|
|
159
|
+
* An MCP error result becomes a rejected call, exactly as the bridge did
|
|
160
|
+
* before: the model must read the error and correct its arguments instead of
|
|
161
|
+
* treating an empty success as an answer.
|
|
162
|
+
* @param {unknown} result - the raw MCP result.
|
|
163
|
+
* @param {string} rawName - the server's own tool name.
|
|
164
|
+
* @returns {{content: unknown[], structuredContent?: unknown}} the canonical value.
|
|
165
|
+
* @throws {Error} when the result is malformed, or when it is an error result.
|
|
166
|
+
*/
|
|
167
|
+
export function toToolValue(result, rawName) {
|
|
168
|
+
const value = /** @type {{content?: unknown, isError?: unknown, structuredContent?: unknown}} */ (result)
|
|
169
|
+
if (typeof value !== 'object' || value === null || !Array.isArray(value.content)) {
|
|
170
|
+
throw new Error(`${publicToolName(rawName)} returned an invalid MCP result without a content array`)
|
|
171
|
+
}
|
|
172
|
+
if (value.isError === true) throw new Error(renderContent(value.content, rawName))
|
|
173
|
+
return {
|
|
174
|
+
content: value.content,
|
|
175
|
+
...(value.structuredContent !== undefined ? { structuredContent: value.structuredContent } : {}),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One registered tool definition for one FastCtx tool.
|
|
181
|
+
* @param {object} options - the definition inputs.
|
|
182
|
+
* @param {{name: string, description?: string, inputSchema?: Record<string, unknown>}} options.tool - the server's own tool.
|
|
183
|
+
* @param {(args: Record<string, unknown>, exec: unknown) => Promise<{content: unknown[], structuredContent?: unknown}>} options.call - the call body.
|
|
184
|
+
* @returns {object} the definition `ctx.tools.register()` accepts.
|
|
185
|
+
*/
|
|
186
|
+
export function toolDefinition({ tool, call }) {
|
|
187
|
+
tool = projectTool(tool)
|
|
188
|
+
return {
|
|
189
|
+
name: publicToolName(tool.name),
|
|
190
|
+
description: tool.description ?? '',
|
|
191
|
+
// Reads/searches have isolated request/reply IDs and immutable parameters;
|
|
192
|
+
// FastCtx bounds their overlap with its shared file-operation permits.
|
|
193
|
+
...(['grep', 'glob', 'inspect_local_file'].includes(tool.name)
|
|
194
|
+
? { isConcurrencySafe: () => true } : {}),
|
|
195
|
+
parameters: tool.inputSchema ?? { type: 'object', properties: {} },
|
|
196
|
+
output: {
|
|
197
|
+
schema: OUTPUT_SCHEMA,
|
|
198
|
+
render: (_args, value) => [{ type: 'text', text: renderContent(value.content ?? [], tool.name) }],
|
|
199
|
+
},
|
|
200
|
+
execute: (args, exec) => call(args, exec),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* One owned FastCtx connection and the tool generation it publishes.
|
|
206
|
+
*
|
|
207
|
+
* The surface needs exactly one thing from its context — the tool registry this
|
|
208
|
+
* plugin registers into — so that a caller can drive it without a host, and so
|
|
209
|
+
* that there is no seam through which it could reach anything it does not own.
|
|
210
|
+
*/
|
|
211
|
+
export class FastCtxTools {
|
|
212
|
+
/**
|
|
213
|
+
* @param {object} options - the surface inputs.
|
|
214
|
+
* @param {{tools: {register: (definition: object) => () => void}}} options.ctx - this plugin's own registration context.
|
|
215
|
+
* @param {import('./config.js').ResolvedConfig} options.config - the resolved plugin configuration.
|
|
216
|
+
* @param {{file: string, source: string, version: string}} options.runtime - the resolved FastCtx executable.
|
|
217
|
+
* @param {(message: string, level?: string) => void} [options.report] - where asynchronous events are reported.
|
|
218
|
+
*/
|
|
219
|
+
constructor({ ctx, config, runtime, report }) {
|
|
220
|
+
this.ctx = ctx
|
|
221
|
+
this.config = config
|
|
222
|
+
this.runtime = runtime
|
|
223
|
+
this.report = report ?? (() => {})
|
|
224
|
+
/** The live connection, or undefined while the surface is down. */
|
|
225
|
+
this.client = undefined
|
|
226
|
+
/** When the current connection was established; drives the stability rule. */
|
|
227
|
+
this.connectedAt = undefined
|
|
228
|
+
/** Consecutive failed attempts inside the current outage. */
|
|
229
|
+
this.attempts = 0
|
|
230
|
+
/** The pending reconnect timer, if any. */
|
|
231
|
+
this.timer = undefined
|
|
232
|
+
/** Set once the owner disposed the surface; every later event is ignored. */
|
|
233
|
+
this.stopped = false
|
|
234
|
+
/** Live registrations of the current generation, keyed by public name. */
|
|
235
|
+
this.disposers = new Map()
|
|
236
|
+
/** The server's own identity, once a handshake succeeded. */
|
|
237
|
+
this.serverInfo = undefined
|
|
238
|
+
/** The server's own instructions, as the model-facing section text. */
|
|
239
|
+
this.serverInstructions = ''
|
|
240
|
+
this.tools = []
|
|
241
|
+
// Command tools live only in this plugin's child fiber of an authorized
|
|
242
|
+
// agent scope, never in the shared/global layer.
|
|
243
|
+
this.agents = new Map()
|
|
244
|
+
this.ownedJobs = new Map()
|
|
245
|
+
this.authorityAvailable = false
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Resolve the current session authority; no env or preset-label fallback. */
|
|
249
|
+
shellAllowed(session) {
|
|
250
|
+
if (!this.authorityAvailable || !this.config.enableShellTools || !session) return false
|
|
251
|
+
try {
|
|
252
|
+
return this.ctx.get('sandboxPolicy')?.resolve({ session })?.mode === 'danger-full-access'
|
|
253
|
+
} catch { return false }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Publish through a plugin-owned fiber, with the agent scope inherited. */
|
|
257
|
+
attachAgent(agent) {
|
|
258
|
+
if (this.stopped || !agent?.ctx || this.agents.has(agent)) return
|
|
259
|
+
const entry = { agent, ctx: undefined, fiber: undefined, disposers: [] }
|
|
260
|
+
this.agents.set(agent, entry)
|
|
261
|
+
entry.fiber = agent.ctx.plugin((ctx) => {
|
|
262
|
+
ctx.inject(['tools'], toolsCtx => {
|
|
263
|
+
entry.ctx = toolsCtx
|
|
264
|
+
this.refreshAgents()
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
detachAgent(agent) {
|
|
270
|
+
const entry = this.agents.get(agent)
|
|
271
|
+
if (!entry) return
|
|
272
|
+
this.agents.delete(agent)
|
|
273
|
+
for (const dispose of entry.disposers) dispose()
|
|
274
|
+
entry.disposers = []
|
|
275
|
+
this.refreshAgents()
|
|
276
|
+
return entry.fiber?.dispose()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Reconcile only this session's tool layer, without restarting FastCtx. */
|
|
280
|
+
refreshAgent(agent) {
|
|
281
|
+
const entry = this.agents.get(agent)
|
|
282
|
+
if (!entry?.ctx) return
|
|
283
|
+
for (const dispose of entry.disposers) dispose()
|
|
284
|
+
entry.disposers = []
|
|
285
|
+
if (this.stopped) return
|
|
286
|
+
if (!this.shellAllowed(agent.session)) {
|
|
287
|
+
// A child must not inherit an authorized ancestor's command surface.
|
|
288
|
+
const names = (this.ctx.get('tools')?.schemas(agent) ?? []).map(tool => tool.name)
|
|
289
|
+
.filter(name => SHELL_TOOLS.map(publicToolName).includes(name))
|
|
290
|
+
if (names.length) {
|
|
291
|
+
try { entry.disposers.push(entry.ctx.tools.restrict({ deny: names })) }
|
|
292
|
+
catch (error) { this.report(`dsh-ops: inherited command restriction failed: ${messageOf(error)}`, 'warn') }
|
|
293
|
+
}
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
const commandTools = this.tools.filter(tool => SHELL_TOOLS.includes(tool.name))
|
|
297
|
+
if (commandTools.length !== SHELL_TOOLS.length) return
|
|
298
|
+
try {
|
|
299
|
+
for (const tool of commandTools) {
|
|
300
|
+
entry.disposers.push(entry.ctx.tools.register(toolDefinition({
|
|
301
|
+
tool,
|
|
302
|
+
call: (args, exec) => this.#call(tool.name, args, exec),
|
|
303
|
+
})))
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
for (const dispose of entry.disposers) dispose()
|
|
307
|
+
entry.disposers = []
|
|
308
|
+
this.report(`dsh-ops: command publication failed: ${messageOf(error)}`, 'warn')
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
refreshAgents() {
|
|
313
|
+
// Tear down all owned layers before rebuilding: a child may have been
|
|
314
|
+
// attached before its ancestor during HMR. Publish authorized layers first,
|
|
315
|
+
// then mask inheritance for every unauthorized child.
|
|
316
|
+
for (const entry of this.agents.values()) {
|
|
317
|
+
for (const dispose of entry.disposers) dispose()
|
|
318
|
+
entry.disposers = []
|
|
319
|
+
}
|
|
320
|
+
for (const agent of this.agents.keys()) if (this.shellAllowed(agent.session)) this.refreshAgent(agent)
|
|
321
|
+
for (const agent of this.agents.keys()) if (!this.shellAllowed(agent.session)) this.refreshAgent(agent)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The public names currently published, in registration order. */
|
|
325
|
+
names() {
|
|
326
|
+
return [...this.disposers.keys()]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The hosted server's own instructions, already attributed to it.
|
|
331
|
+
*
|
|
332
|
+
* The MCP bridge used to publish these as a prompt section of its own; the
|
|
333
|
+
* plugin keeps the same section name and text shape so the model still reads
|
|
334
|
+
* the server's instructions. Empty while the server is down or sends none.
|
|
335
|
+
* @returns {string} the section text.
|
|
336
|
+
*/
|
|
337
|
+
instructions() {
|
|
338
|
+
return this.serverInstructions
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Connect and publish the first generation of tools.
|
|
343
|
+
* @returns {Promise<string[]>} the published public names; empty when the server listed none.
|
|
344
|
+
* @throws {Error} on any connection failure — the caller decides whether that is fatal.
|
|
345
|
+
*/
|
|
346
|
+
async start() {
|
|
347
|
+
await this.#connect()
|
|
348
|
+
return this.names()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Keep trying in the background after a failed `start()`, with bounded
|
|
353
|
+
* exponential backoff, and republish the whole generation on success.
|
|
354
|
+
* The caller picks this only for a deployment that tolerates a missing
|
|
355
|
+
* server; a fatal one must let `start()` reject instead.
|
|
356
|
+
* @returns {void}
|
|
357
|
+
*/
|
|
358
|
+
retryLater() {
|
|
359
|
+
if (this.stopped || this.client !== undefined) return
|
|
360
|
+
this.#scheduleReconnect()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Unregister every tool this surface published and stop the child process.
|
|
365
|
+
*
|
|
366
|
+
* Idempotent, and silent about work that was in flight when it was called.
|
|
367
|
+
* @returns {Promise<void>} resolves once the child is gone.
|
|
368
|
+
*/
|
|
369
|
+
async stop() {
|
|
370
|
+
this.stopped = true
|
|
371
|
+
if (this.timer !== undefined) {
|
|
372
|
+
clearTimeout(this.timer)
|
|
373
|
+
this.timer = undefined
|
|
374
|
+
}
|
|
375
|
+
const client = this.client
|
|
376
|
+
this.client = undefined
|
|
377
|
+
this.connectedAt = undefined
|
|
378
|
+
this.serverInstructions = ''
|
|
379
|
+
this.#unpublish()
|
|
380
|
+
this.tools = []
|
|
381
|
+
this.ownedJobs.clear()
|
|
382
|
+
await Promise.all([...this.agents.keys()].map(agent => this.detachAgent(agent)))
|
|
383
|
+
if (client !== undefined) await client.close()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Spawn one server, handshake, list its tools, and swap in the new generation.
|
|
388
|
+
* @returns {Promise<void>} resolves once the generation is live.
|
|
389
|
+
*/
|
|
390
|
+
async #connect() {
|
|
391
|
+
const client = McpStdioClient.start({
|
|
392
|
+
file: this.runtime.file,
|
|
393
|
+
args: serverArgs(this.config.enableShellTools),
|
|
394
|
+
env: childEnv(),
|
|
395
|
+
timeoutMs: CONNECT_TIMEOUT_MS,
|
|
396
|
+
})
|
|
397
|
+
this.client = client
|
|
398
|
+
// Installed before the handshake: a crash is an event, not a failed
|
|
399
|
+
// request, and it must be noticed between calls rather than on the next
|
|
400
|
+
// timeout. The identity guard drops the exit of a connection this surface
|
|
401
|
+
// already replaced or abandoned.
|
|
402
|
+
void client.exited.then(({ code, signal, error }) => this.#onExit(client, code, signal, error))
|
|
403
|
+
try {
|
|
404
|
+
const handshake = await client.initialize('dsh-ops')
|
|
405
|
+
const tools = await client.listTools()
|
|
406
|
+
if (this.stopped || client !== this.client) throw new StoppedError()
|
|
407
|
+
this.ownedJobs.clear()
|
|
408
|
+
this.#publish(tools)
|
|
409
|
+
this.serverInfo = handshake?.serverInfo
|
|
410
|
+
// The single routing table replaces overlapping server instructions.
|
|
411
|
+
this.serverInstructions = ''
|
|
412
|
+
this.connectedAt = Date.now()
|
|
413
|
+
} catch (error) {
|
|
414
|
+
await client.close()
|
|
415
|
+
if (this.client === client) this.client = undefined
|
|
416
|
+
throw error
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* React to one connection ending: report it, then reconnect with backoff.
|
|
422
|
+
* @param {McpStdioClient} client - the connection that ended.
|
|
423
|
+
* @param {number|null} code - the exit code.
|
|
424
|
+
* @param {string|null} signal - the terminating signal.
|
|
425
|
+
* @param {Error} [error] - the spawn error, when the server never started.
|
|
426
|
+
* @returns {void}
|
|
427
|
+
*/
|
|
428
|
+
#onExit(client, code, signal, error) {
|
|
429
|
+
if (this.stopped || client !== this.client) return
|
|
430
|
+
this.client = undefined
|
|
431
|
+
this.ownedJobs.clear()
|
|
432
|
+
this.tools = []
|
|
433
|
+
this.#unpublish()
|
|
434
|
+
this.refreshAgents()
|
|
435
|
+
this.connectedAt = undefined
|
|
436
|
+
this.report(
|
|
437
|
+
`dsh-ops: ${this.#describe()} ${error !== undefined
|
|
438
|
+
? `could not be started (${error.message})`
|
|
439
|
+
: `exited (code ${code ?? 'null'}, signal ${signal ?? 'none'})`}; `
|
|
440
|
+
+ 'the published tools are withdrawn until it is back.',
|
|
441
|
+
)
|
|
442
|
+
this.#scheduleReconnect()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Wait, then try once more, then either recover or spend another attempt.
|
|
447
|
+
* @returns {void}
|
|
448
|
+
*/
|
|
449
|
+
#scheduleReconnect() {
|
|
450
|
+
// One pending timer at a time: a crash and a failed first connect can both
|
|
451
|
+
// land here, and two loops would double every later attempt.
|
|
452
|
+
if (this.stopped || this.timer !== undefined) return
|
|
453
|
+
// A connection that stayed up long enough closed the previous outage.
|
|
454
|
+
if (this.connectedAt !== undefined && Date.now() - this.connectedAt >= RECONNECT.maxDelayMs) {
|
|
455
|
+
this.attempts = 0
|
|
456
|
+
}
|
|
457
|
+
this.connectedAt = undefined
|
|
458
|
+
this.attempts += 1
|
|
459
|
+
if (this.attempts > RECONNECT.maxAttempts) {
|
|
460
|
+
// Giving up must not leave phantom tools behind: the model would keep
|
|
461
|
+
// calling a surface nothing answers.
|
|
462
|
+
this.#unpublish()
|
|
463
|
+
this.serverInstructions = ''
|
|
464
|
+
this.report(
|
|
465
|
+
`dsh-ops: giving up on FastCtx after ${RECONNECT.maxAttempts} consecutive failed reconnect `
|
|
466
|
+
+ 'attempts; the ops_ tools are unavailable until the plugin is reloaded.',
|
|
467
|
+
)
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
const delayMs = Math.min(RECONNECT.maxDelayMs, RECONNECT.initialDelayMs * 2 ** (this.attempts - 1))
|
|
471
|
+
this.report(
|
|
472
|
+
`dsh-ops: FastCtx is down; reconnecting in ${delayMs}ms `
|
|
473
|
+
+ `(attempt ${this.attempts}/${RECONNECT.maxAttempts}).`,
|
|
474
|
+
'warn',
|
|
475
|
+
)
|
|
476
|
+
this.timer = setTimeout(() => {
|
|
477
|
+
this.timer = undefined
|
|
478
|
+
void this.#reconnect()
|
|
479
|
+
}, delayMs)
|
|
480
|
+
this.timer.unref?.()
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* One scheduled reconnect attempt: publish a fresh generation, or spend the
|
|
485
|
+
* next attempt. Never rejects and never takes the plugin down.
|
|
486
|
+
* @returns {Promise<void>} resolves once this attempt settled.
|
|
487
|
+
*/
|
|
488
|
+
async #reconnect() {
|
|
489
|
+
if (this.stopped) return
|
|
490
|
+
try {
|
|
491
|
+
await this.#connect()
|
|
492
|
+
this.report(`dsh-ops: ${this.#describe()} reconnected with ${this.names().length} tool(s).`)
|
|
493
|
+
} catch (error) {
|
|
494
|
+
if (this.stopped) return
|
|
495
|
+
this.report(
|
|
496
|
+
`dsh-ops: FastCtx reconnect attempt ${this.attempts}/${RECONNECT.maxAttempts} `
|
|
497
|
+
+ `failed (${messageOf(error)}).`,
|
|
498
|
+
'warn',
|
|
499
|
+
)
|
|
500
|
+
this.#scheduleReconnect()
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Publish one whole generation, or leave the previous one untouched.
|
|
506
|
+
*
|
|
507
|
+
* The next generation is built completely before the live one is disposed, so
|
|
508
|
+
* a failed `tools/list` or a malformed definition costs nothing. A failure
|
|
509
|
+
* while registering rolls the partial generation back and surfaces as a
|
|
510
|
+
* connection failure: a registry conflict can only mean a foreign
|
|
511
|
+
* registration squats on this plugin's own `ops_` names.
|
|
512
|
+
* @param {{name: string, description?: string, inputSchema?: Record<string, unknown>}[]} tools - the server's tool list.
|
|
513
|
+
* @returns {void}
|
|
514
|
+
*/
|
|
515
|
+
#publish(tools) {
|
|
516
|
+
const definitions = []
|
|
517
|
+
const seen = new Set()
|
|
518
|
+
for (const tool of tools) {
|
|
519
|
+
const name = publicToolName(String(tool?.name ?? ''))
|
|
520
|
+
if (seen.has(name)) {
|
|
521
|
+
throw new Error(`${this.#describe()} listed the tool "${String(tool?.name)}" more than once`)
|
|
522
|
+
}
|
|
523
|
+
seen.add(name)
|
|
524
|
+
if (!FILE_TOOLS.includes(tool.name)) continue
|
|
525
|
+
definitions.push(toolDefinition({
|
|
526
|
+
tool,
|
|
527
|
+
call: (args, exec) => this.#call(tool.name, args, exec),
|
|
528
|
+
}))
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
this.#unpublish()
|
|
532
|
+
const disposers = new Map()
|
|
533
|
+
try {
|
|
534
|
+
for (const definition of definitions) {
|
|
535
|
+
disposers.set(definition.name, this.ctx.tools.register(definition))
|
|
536
|
+
}
|
|
537
|
+
} catch (error) {
|
|
538
|
+
for (const dispose of disposers.values()) dispose()
|
|
539
|
+
throw error
|
|
540
|
+
}
|
|
541
|
+
this.disposers = disposers
|
|
542
|
+
this.tools = tools
|
|
543
|
+
this.refreshAgents()
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Release every registration of the current generation. @returns {void} */
|
|
547
|
+
#unpublish() {
|
|
548
|
+
for (const dispose of this.disposers.values()) dispose()
|
|
549
|
+
this.disposers = new Map()
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Call one FastCtx tool and map its result onto the host contract.
|
|
554
|
+
* @param {string} rawName - the server's own tool name.
|
|
555
|
+
* @param {Record<string, unknown>} args - the arguments.
|
|
556
|
+
* @param {{signal?: AbortSignal, agent?: {session?: object}}} [exec] - the host execution context.
|
|
557
|
+
* @returns {Promise<{content: unknown[], structuredContent?: unknown}>} the canonical value.
|
|
558
|
+
*/
|
|
559
|
+
async #call(rawName, args, exec) {
|
|
560
|
+
const label = publicToolName(rawName)
|
|
561
|
+
const session = exec?.agent?.session
|
|
562
|
+
if (SHELL_TOOLS.includes(rawName) && !this.shellAllowed(session)) {
|
|
563
|
+
throw new Error('Command and job tools require authoritative danger-full-access for this session.')
|
|
564
|
+
}
|
|
565
|
+
if (rawName === 'inspect_local_file') {
|
|
566
|
+
if (args?.pdf_mode === 'image') throw new Error('PDF images are unsupported; use PDF text mode or host read_image for an image file.')
|
|
567
|
+
const paths = args?.files?.map?.(entry => entry?.path) ?? [args?.file_path]
|
|
568
|
+
if (args?.view !== 'hex' && paths.some(path => /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(String(path ?? '')))) {
|
|
569
|
+
throw new Error('This is an image; use host read_image.')
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const client = this.client
|
|
573
|
+
if (client === undefined) {
|
|
574
|
+
// A dead server must be an immediate, explicit failure rather than a
|
|
575
|
+
// request that waits out the whole call deadline.
|
|
576
|
+
throw new Error(
|
|
577
|
+
`${label} is unavailable: the FastCtx server is not connected `
|
|
578
|
+
+ `(reconnect attempt ${this.attempts}/${RECONNECT.maxAttempts} in progress; retry shortly)`,
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
let owned = this.ownedJobs.get(session) ?? new Set()
|
|
582
|
+
if ((rawName === 'job_output' || rawName === 'job_kill') && !owned.has(args?.job_id)) {
|
|
583
|
+
throw new Error('This job was not started by this session on the current connection.')
|
|
584
|
+
}
|
|
585
|
+
if (rawName === 'job_list') return this.#listOwnedJobs(client, session, owned, args, exec)
|
|
586
|
+
let result
|
|
587
|
+
try {
|
|
588
|
+
result = await client.callTool(rawName, args ?? {}, {
|
|
589
|
+
timeoutMs: this.config.toolCallTimeoutMs,
|
|
590
|
+
signal: exec?.signal,
|
|
591
|
+
})
|
|
592
|
+
} catch (error) {
|
|
593
|
+
throw new Error(`${label} failed: ${messageOf(error)}`, { cause: error })
|
|
594
|
+
}
|
|
595
|
+
let value = toToolValue(result, rawName)
|
|
596
|
+
if (rawName === 'inspect_local_file' && value.content.some(block => block?.type === 'image')) {
|
|
597
|
+
throw new Error('This result contains an image; use host read_image. For PDFs, request the text layer.')
|
|
598
|
+
}
|
|
599
|
+
if (client !== this.client || this.stopped) throw new Error('FastCtx connection changed; retry on the current connection.')
|
|
600
|
+
if (rawName === 'run_background') {
|
|
601
|
+
const id = startedJob(value)
|
|
602
|
+
if (!id) throw new Error('The background launch did not return a recognized job ID; it cannot be managed by this session.')
|
|
603
|
+
owned = this.ownedJobs.get(session) ?? owned
|
|
604
|
+
owned.add(id)
|
|
605
|
+
this.ownedJobs.set(session, owned)
|
|
606
|
+
}
|
|
607
|
+
value = cleanBackground(value, owned)
|
|
608
|
+
return value
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Aggregate upstream pages privately; do not expose global counts/offsets. */
|
|
612
|
+
async #listOwnedJobs(client, session, owned, args, exec) {
|
|
613
|
+
const limit = args?.limit ?? 20
|
|
614
|
+
const offset = args?.offset ?? 0
|
|
615
|
+
const status = args?.status ?? 'running'
|
|
616
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|
|
617
|
+
|| !Number.isInteger(offset) || offset < 0
|
|
618
|
+
|| !['running', 'finished', 'all'].includes(status)) {
|
|
619
|
+
throw new Error('Invalid job list parameters: limit 1..100, offset >= 0, status running/finished/all.')
|
|
620
|
+
}
|
|
621
|
+
if (!owned.size) return { content: [{ type: 'text', text: '(Complete: no owned jobs.)' }] }
|
|
622
|
+
const selected = new Map()
|
|
623
|
+
let cursor = 0
|
|
624
|
+
let complete = false
|
|
625
|
+
const deadline = Date.now() + this.config.toolCallTimeoutMs
|
|
626
|
+
// Bounded scanning; if the durable global store is too large, report an
|
|
627
|
+
// incomplete owned inventory without leaking or inventing global offsets.
|
|
628
|
+
for (let page = 0; page < 100 && Date.now() < deadline; page += 1) {
|
|
629
|
+
if (client !== this.client || !this.shellAllowed(session)) throw new Error('Connection or permission changed during job listing.')
|
|
630
|
+
const value = toToolValue(await client.callTool('job_list', { ...args, status, limit: 100, offset: cursor }, {
|
|
631
|
+
timeoutMs: Math.max(1, deadline - Date.now()), signal: exec?.signal,
|
|
632
|
+
}), 'job_list')
|
|
633
|
+
const parsed = jobEntries(value)
|
|
634
|
+
for (const entry of parsed.entries) if (owned.has(entry.id)) selected.set(entry.id, entry)
|
|
635
|
+
if (!parsed.partial) { complete = true; break }
|
|
636
|
+
const next = parsed.next ?? cursor + parsed.entries.length
|
|
637
|
+
if (next <= cursor) break
|
|
638
|
+
cursor = next
|
|
639
|
+
}
|
|
640
|
+
if (client !== this.client || !this.shellAllowed(session)) throw new Error('Connection or permission changed during job listing.')
|
|
641
|
+
const entries = [...selected.values()]
|
|
642
|
+
const shown = entries.slice(offset, offset + limit)
|
|
643
|
+
const more = offset + shown.length < entries.length
|
|
644
|
+
const marker = more
|
|
645
|
+
? `(Partial: more owned jobs; offset=${offset + shown.length}.)`
|
|
646
|
+
: complete ? `(Complete: ${entries.length} owned jobs.)` : '(Partial: owned job inventory scan limit reached; use known job IDs.)'
|
|
647
|
+
return { content: [{ type: 'text', text: [...shown.map(entry => entry.text), marker].join('\n\n') }] }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* The runtime identity used in reports.
|
|
652
|
+
* @returns {string} a human-readable description.
|
|
653
|
+
*/
|
|
654
|
+
#describe() {
|
|
655
|
+
return `FastCtx ${this.runtime.version || 'unknown version'} (${this.runtime.source})`
|
|
656
|
+
}
|
|
657
|
+
}
|
package/locale/en.json
ADDED