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.
Files changed (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. 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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Repository tools",
4
+ "description": "Read, search, replace, and run commands through the hosted FastCtx ops_* tool surface instead of shell commands."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "仓库工具",
4
+ "description": "通过托管的 FastCtx ops_* 工具完成读取、搜索、替换与命令执行,替代 shell 命令。"
5
+ }
6
+ }