@shawnstack/quickforge 1.7.1 → 1.7.3
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/README.md +1 -0
- package/bin/quickforge.mjs +2 -0
- package/dist/assets/AgentProfilesPage-D7pbg8nm.js +1 -0
- package/dist/assets/ChatPanelHost-wfGCaCzD.js +288 -0
- package/dist/assets/{PluginsPage-CKyWhlCo.js → PluginsPage-NAGroBm3.js} +1 -1
- package/dist/assets/ScheduledTasksPage-Bp0MNfQD.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-DwqEnUmX.js → SettingsWorkspacePage-BtJZMIsZ.js} +425 -344
- package/dist/assets/SharedConversationPage-Ce3KoVZt.js +1 -0
- package/dist/assets/{TerminalDock-B9xKnimU.js → TerminalDock-4eUuXP7X.js} +2 -2
- package/dist/assets/WorkspaceInspector-kM3BRPjY.js +13 -0
- package/dist/assets/icons-BP8YOS-Z.js +1 -0
- package/dist/assets/index-C6k5taeb.js +66 -0
- package/dist/assets/index-CzN8NSKC.css +3 -0
- package/dist/assets/mcp-servers-dialog-BQbFywVL.js +5 -0
- package/dist/assets/{monaco-CPwJMUsl.js → monaco-BTsVCDWS.js} +1 -1
- package/dist/assets/{react-vendor-CLbWF1Oy.js → react-vendor-Dr5xvL-e.js} +1 -1
- package/dist/assets/{skills-dialog-D18mxNyW.js → skills-dialog-REBeTFSH.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +4 -1
- package/server/acp/server.mjs +19 -7
- package/server/ai-http-logger.mjs +6 -6
- package/server/ai-provider-options.mjs +8 -0
- package/server/index.mjs +7 -2
- package/server/mcp/registry.mjs +54 -14
- package/server/network-proxy.mjs +384 -0
- package/server/plugins/loader.mjs +9 -1
- package/server/plugins/registry.mjs +32 -10
- package/server/public-api.mjs +4 -0
- package/server/routes/agent-profiles.mjs +2 -0
- package/server/routes/mcp.mjs +0 -5
- package/server/routes/models.mjs +1 -0
- package/server/routes/scheduled-tasks.mjs +74 -32
- package/server/routes/system.mjs +26 -0
- package/server/routes/workspace.mjs +89 -17
- package/server/session-utils.mjs +2 -0
- package/server/utils/scheduled-tasks.mjs +23 -10
- package/server/utils/workspace.mjs +25 -3
- package/dist/assets/AgentProfilesPage-__uY0AvK.js +0 -1
- package/dist/assets/ChatPanelHost-DIs_vWFX.js +0 -291
- package/dist/assets/ScheduledTasksPage-KtJoeJYt.js +0 -2
- package/dist/assets/SharedConversationPage-yujCRsbe.js +0 -1
- package/dist/assets/WorkspaceInspector-CE6RD6Ys.js +0 -13
- package/dist/assets/icons-pPRMD2tE.js +0 -1
- package/dist/assets/index-BSXpUDCq.js +0 -63
- package/dist/assets/index-DpO7jEGP.css +0 -3
- package/dist/assets/mcp-servers-dialog-CaQTvGiW.js +0 -5
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import tls from 'node:tls'
|
|
2
|
+
import { Agent, DecoratorHandler, Dispatcher, ProxyAgent } from 'undici'
|
|
3
|
+
import { SocksClient } from 'socks'
|
|
4
|
+
import { atomicUpdate, readStore } from './storage.mjs'
|
|
5
|
+
|
|
6
|
+
const SETTINGS_KEY = 'network-proxy'
|
|
7
|
+
const VALID_MODES = new Set(['direct', 'system', 'manual'])
|
|
8
|
+
const PROXY_ENV_KEYS = [
|
|
9
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
|
10
|
+
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
|
|
11
|
+
]
|
|
12
|
+
const ORIGINAL_FETCH = globalThis.fetch.bind(globalThis)
|
|
13
|
+
const FETCH_PATCH_MARKER = Symbol.for('quickforge.networkProxy.fetchPatched')
|
|
14
|
+
|
|
15
|
+
let currentConfig = { mode: 'direct', proxyUrl: '' }
|
|
16
|
+
let hostRuntime = null
|
|
17
|
+
let nativeResolver = null
|
|
18
|
+
let nativeResolverError = null
|
|
19
|
+
let networkDispatcher = null
|
|
20
|
+
let initialized = false
|
|
21
|
+
let lastAppliedAt = null
|
|
22
|
+
|
|
23
|
+
export function normalizeNetworkProxyConfig(value) {
|
|
24
|
+
const mode = VALID_MODES.has(value?.mode) ? value.mode : 'direct'
|
|
25
|
+
const proxyUrl = typeof value?.proxyUrl === 'string' ? value.proxyUrl.trim() : ''
|
|
26
|
+
return { mode, proxyUrl }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateManualProxyUrl(value) {
|
|
30
|
+
let url
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(String(value || '').trim())
|
|
33
|
+
} catch {
|
|
34
|
+
const error = new Error('Proxy address must be a valid HTTP or HTTPS URL')
|
|
35
|
+
error.statusCode = 400
|
|
36
|
+
throw error
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
40
|
+
const error = new Error('Only HTTP and HTTPS proxy addresses are supported')
|
|
41
|
+
error.statusCode = 400
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
if (!url.hostname || !url.port) {
|
|
45
|
+
const error = new Error('Proxy address must include a host and port')
|
|
46
|
+
error.statusCode = 400
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
if (url.username || url.password) {
|
|
50
|
+
const error = new Error('Proxy credentials are not supported in the proxy address')
|
|
51
|
+
error.statusCode = 400
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
if ((url.pathname && url.pathname !== '/') || url.search || url.hash) {
|
|
55
|
+
const error = new Error('Proxy address cannot include a path, query, or fragment')
|
|
56
|
+
error.statusCode = 400
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${url.protocol}//${url.host}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isLoopbackUrl(input) {
|
|
64
|
+
let url
|
|
65
|
+
try {
|
|
66
|
+
if (typeof Request !== 'undefined' && input instanceof Request) url = new URL(input.url)
|
|
67
|
+
else url = new URL(input instanceof URL ? input.href : String(input))
|
|
68
|
+
} catch {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
return ['localhost', '127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(url.hostname)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function withoutProxyEnvironment(callback) {
|
|
75
|
+
const previous = new Map()
|
|
76
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
77
|
+
previous.set(key, process.env[key])
|
|
78
|
+
delete process.env[key]
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return callback()
|
|
82
|
+
} finally {
|
|
83
|
+
for (const [key, value] of previous) {
|
|
84
|
+
if (value === undefined) delete process.env[key]
|
|
85
|
+
else process.env[key] = value
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function getNativeResolver() {
|
|
91
|
+
if (nativeResolver) return nativeResolver
|
|
92
|
+
if (nativeResolverError) throw nativeResolverError
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const { ProxyResolver } = await import('@vscode/os-proxy-resolver')
|
|
96
|
+
nativeResolver = withoutProxyEnvironment(() => new ProxyResolver())
|
|
97
|
+
return nativeResolver
|
|
98
|
+
} catch (error) {
|
|
99
|
+
nativeResolverError = error instanceof Error ? error : new Error(String(error))
|
|
100
|
+
throw nativeResolverError
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseProxyHost(value) {
|
|
105
|
+
const raw = String(value || '').trim()
|
|
106
|
+
const url = new URL(raw.includes('://') ? raw : `http://${raw}`)
|
|
107
|
+
return {
|
|
108
|
+
host: url.hostname,
|
|
109
|
+
port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
|
|
110
|
+
secure: url.protocol === 'https:',
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function createSocksConnector(proxyHost) {
|
|
115
|
+
const proxy = parseProxyHost(proxyHost)
|
|
116
|
+
return (options, callback) => {
|
|
117
|
+
const destinationHost = options.hostname || options.host
|
|
118
|
+
const destinationPort = Number(options.port || (options.protocol === 'https:' ? 443 : 80))
|
|
119
|
+
let settled = false
|
|
120
|
+
|
|
121
|
+
const complete = (error, socket) => {
|
|
122
|
+
if (settled) return
|
|
123
|
+
settled = true
|
|
124
|
+
callback(error, socket)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
SocksClient.createConnection({
|
|
128
|
+
proxy: {
|
|
129
|
+
host: proxy.host,
|
|
130
|
+
port: proxy.port,
|
|
131
|
+
type: 5,
|
|
132
|
+
},
|
|
133
|
+
command: 'connect',
|
|
134
|
+
destination: {
|
|
135
|
+
host: destinationHost,
|
|
136
|
+
port: destinationPort,
|
|
137
|
+
},
|
|
138
|
+
timeout: Number(options.timeout || 10000),
|
|
139
|
+
}).then(({ socket }) => {
|
|
140
|
+
if (options.protocol !== 'https:') {
|
|
141
|
+
complete(null, socket)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const secureSocket = tls.connect({
|
|
145
|
+
socket,
|
|
146
|
+
servername: options.servername || destinationHost,
|
|
147
|
+
ALPNProtocols: ['http/1.1'],
|
|
148
|
+
})
|
|
149
|
+
secureSocket.once('secureConnect', () => complete(null, secureSocket))
|
|
150
|
+
secureSocket.once('error', (error) => complete(error))
|
|
151
|
+
}).catch((error) => complete(error))
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
settled = true
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class RetryHandler extends DecoratorHandler {
|
|
160
|
+
constructor(dispatcher, options, handler, retry) {
|
|
161
|
+
super(handler)
|
|
162
|
+
this.dispatcher = dispatcher
|
|
163
|
+
this.options = options
|
|
164
|
+
this.retry = retry
|
|
165
|
+
this.receivedResponse = false
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
onResponseStart(controller, statusCode, headers, statusMessage) {
|
|
169
|
+
this.receivedResponse = true
|
|
170
|
+
return super.onResponseStart(controller, statusCode, headers, statusMessage)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
onResponseError(controller, error) {
|
|
174
|
+
if (!this.receivedResponse) {
|
|
175
|
+
void this.retry(error)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
return super.onResponseError(controller, error)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
class SystemProxyDispatcher extends Dispatcher {
|
|
183
|
+
constructor(resolver) {
|
|
184
|
+
super()
|
|
185
|
+
this.resolver = resolver
|
|
186
|
+
this.directAgent = new Agent()
|
|
187
|
+
this.dispatchers = new Map()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
dispatcherFor(route) {
|
|
191
|
+
if (!route || route.kind === 'direct') return this.directAgent
|
|
192
|
+
const key = `${route.kind}:${route.host}`
|
|
193
|
+
if (this.dispatchers.has(key)) return this.dispatchers.get(key)
|
|
194
|
+
|
|
195
|
+
let dispatcher
|
|
196
|
+
if (route.kind === 'http') {
|
|
197
|
+
const proxy = parseProxyHost(route.host)
|
|
198
|
+
dispatcher = new ProxyAgent({ uri: `${proxy.secure ? 'https' : 'http'}://${proxy.host}:${proxy.port}` })
|
|
199
|
+
} else if (route.kind === 'socks') {
|
|
200
|
+
dispatcher = new Agent({ connect: createSocksConnector(route.host) })
|
|
201
|
+
} else {
|
|
202
|
+
dispatcher = this.directAgent
|
|
203
|
+
}
|
|
204
|
+
this.dispatchers.set(key, dispatcher)
|
|
205
|
+
return dispatcher
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
dispatch(options, handler) {
|
|
209
|
+
const url = `${options.origin}${options.path}`
|
|
210
|
+
let routes = []
|
|
211
|
+
let index = 0
|
|
212
|
+
|
|
213
|
+
const tryNext = async (lastError) => {
|
|
214
|
+
if (index >= routes.length) {
|
|
215
|
+
handler.onResponseError?.(null, lastError || new Error('No usable system proxy route'))
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
const route = routes[index]
|
|
219
|
+
index += 1
|
|
220
|
+
const dispatcher = this.dispatcherFor(route)
|
|
221
|
+
const retryHandler = new RetryHandler(dispatcher, options, handler, async (error) => {
|
|
222
|
+
if (route?.kind !== 'direct') this.resolver.reportProxyFailed(route)
|
|
223
|
+
await tryNext(error)
|
|
224
|
+
})
|
|
225
|
+
try {
|
|
226
|
+
dispatcher.dispatch(options, retryHandler)
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (route?.kind !== 'direct') this.resolver.reportProxyFailed(route)
|
|
229
|
+
await tryNext(error)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
void this.resolver.resolve(url).then((resolvedRoutes) => {
|
|
234
|
+
routes = Array.isArray(resolvedRoutes) && resolvedRoutes.length ? resolvedRoutes : [{ kind: 'direct' }]
|
|
235
|
+
return tryNext()
|
|
236
|
+
}).catch((error) => handler.onResponseError?.(null, error))
|
|
237
|
+
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async close() {
|
|
242
|
+
const dispatchers = [this.directAgent, ...this.dispatchers.values()]
|
|
243
|
+
await Promise.allSettled(dispatchers.map((dispatcher) => dispatcher.close()))
|
|
244
|
+
this.dispatchers.clear()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async destroy(error) {
|
|
248
|
+
const dispatchers = [this.directAgent, ...this.dispatchers.values()]
|
|
249
|
+
await Promise.allSettled(dispatchers.map((dispatcher) => dispatcher.destroy(error)))
|
|
250
|
+
this.dispatchers.clear()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function createNetworkDispatcher(config) {
|
|
255
|
+
if (config.mode === 'manual') return new ProxyAgent(config.proxyUrl)
|
|
256
|
+
if (config.mode === 'system') return new SystemProxyDispatcher(await getNativeResolver())
|
|
257
|
+
return new Agent()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function replaceNetworkDispatcher(config) {
|
|
261
|
+
const previous = networkDispatcher
|
|
262
|
+
networkDispatcher = await createNetworkDispatcher(config)
|
|
263
|
+
if (previous) void previous.close().catch(() => {})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function installNetworkFetch() {
|
|
267
|
+
if (globalThis[FETCH_PATCH_MARKER]) return
|
|
268
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
269
|
+
if (isLoopbackUrl(input)) return ORIGINAL_FETCH(input, init)
|
|
270
|
+
if (hostRuntime) {
|
|
271
|
+
if (currentConfig.mode === 'direct') return ORIGINAL_FETCH(input, init)
|
|
272
|
+
return hostRuntime.fetch(input, init)
|
|
273
|
+
}
|
|
274
|
+
if (!networkDispatcher) return ORIGINAL_FETCH(input, init)
|
|
275
|
+
return ORIGINAL_FETCH(input, { ...init, dispatcher: networkDispatcher })
|
|
276
|
+
}
|
|
277
|
+
globalThis[FETCH_PATCH_MARKER] = true
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function registerHostNetworkRuntime(runtime) {
|
|
281
|
+
hostRuntime = runtime || null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function persistConfig(config) {
|
|
285
|
+
await atomicUpdate('settings', (settings) => ({
|
|
286
|
+
...settings,
|
|
287
|
+
[SETTINGS_KEY]: config,
|
|
288
|
+
}))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function applyConfig(config) {
|
|
292
|
+
const normalized = normalizeNetworkProxyConfig(config)
|
|
293
|
+
if (normalized.mode === 'manual') normalized.proxyUrl = validateManualProxyUrl(normalized.proxyUrl)
|
|
294
|
+
|
|
295
|
+
if (hostRuntime) await hostRuntime.apply(normalized)
|
|
296
|
+
else await replaceNetworkDispatcher(normalized)
|
|
297
|
+
|
|
298
|
+
currentConfig = normalized
|
|
299
|
+
lastAppliedAt = new Date().toISOString()
|
|
300
|
+
return currentConfig
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function nativeStatus() {
|
|
304
|
+
try {
|
|
305
|
+
const resolver = await getNativeResolver()
|
|
306
|
+
const config = await resolver.readProxyConfig()
|
|
307
|
+
const source = config.platform?.kind || process.platform
|
|
308
|
+
return {
|
|
309
|
+
supported: true,
|
|
310
|
+
source,
|
|
311
|
+
features: {
|
|
312
|
+
pac: config.configuredPac?.state !== 'unsupported',
|
|
313
|
+
wpad: config.wpadDns?.state !== 'unsupported' || config.wpadDhcp?.state !== 'unsupported',
|
|
314
|
+
httpProxy: true,
|
|
315
|
+
httpsProxy: true,
|
|
316
|
+
socks: true,
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return {
|
|
321
|
+
supported: false,
|
|
322
|
+
source: 'none',
|
|
323
|
+
features: { pac: false, wpad: false, httpProxy: false, httpsProxy: false, socks: false },
|
|
324
|
+
error: error instanceof Error ? error.message : String(error),
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function getNetworkProxyStatus() {
|
|
330
|
+
const runtimeStatus = hostRuntime
|
|
331
|
+
? await hostRuntime.getStatus()
|
|
332
|
+
: await nativeStatus()
|
|
333
|
+
const configuredMode = currentConfig.mode
|
|
334
|
+
const modeSupported = configuredMode !== 'system' || runtimeStatus.supported
|
|
335
|
+
return {
|
|
336
|
+
configuredMode,
|
|
337
|
+
effectiveMode: modeSupported ? configuredMode : 'unsupported',
|
|
338
|
+
proxyUrl: currentConfig.proxyUrl,
|
|
339
|
+
runtimeKind: hostRuntime ? 'electron-inline' : (process.versions.electron ? 'electron-node' : 'node'),
|
|
340
|
+
lastAppliedAt,
|
|
341
|
+
...runtimeStatus,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function initializeNetworkProxy() {
|
|
346
|
+
if (initialized) return { config: currentConfig, status: await getNetworkProxyStatus() }
|
|
347
|
+
installNetworkFetch()
|
|
348
|
+
const settings = await readStore('settings')
|
|
349
|
+
await applyConfig(settings?.[SETTINGS_KEY])
|
|
350
|
+
initialized = true
|
|
351
|
+
return { config: currentConfig, status: await getNetworkProxyStatus() }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function getNetworkProxyConfig() {
|
|
355
|
+
if (!initialized) await initializeNetworkProxy()
|
|
356
|
+
return { config: currentConfig, status: await getNetworkProxyStatus() }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function updateNetworkProxyConfig(value) {
|
|
360
|
+
if (!initialized) await initializeNetworkProxy()
|
|
361
|
+
const previous = currentConfig
|
|
362
|
+
const next = normalizeNetworkProxyConfig(value)
|
|
363
|
+
if (next.mode === 'manual') next.proxyUrl = validateManualProxyUrl(next.proxyUrl)
|
|
364
|
+
|
|
365
|
+
await applyConfig(next)
|
|
366
|
+
try {
|
|
367
|
+
await persistConfig(currentConfig)
|
|
368
|
+
} catch (error) {
|
|
369
|
+
await applyConfig(previous)
|
|
370
|
+
throw error
|
|
371
|
+
}
|
|
372
|
+
return { config: currentConfig, status: await getNetworkProxyStatus() }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function refreshSystemProxy() {
|
|
376
|
+
if (!initialized) await initializeNetworkProxy()
|
|
377
|
+
nativeResolver?.close?.()
|
|
378
|
+
nativeResolver = null
|
|
379
|
+
nativeResolverError = null
|
|
380
|
+
if (hostRuntime) await hostRuntime.refresh()
|
|
381
|
+
else await replaceNetworkDispatcher(currentConfig)
|
|
382
|
+
lastAppliedAt = new Date().toISOString()
|
|
383
|
+
return { config: currentConfig, status: await getNetworkProxyStatus() }
|
|
384
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
1
3
|
import { pathToFileURL } from 'node:url'
|
|
2
4
|
|
|
3
5
|
function isPlainObject(value) {
|
|
@@ -19,7 +21,9 @@ function contentToText(result) {
|
|
|
19
21
|
|
|
20
22
|
export async function loadPlugin(manifest, context = {}) {
|
|
21
23
|
const mainPath = new URL(manifest.main, pathToFileURL(`${manifest.dir}/`))
|
|
22
|
-
const
|
|
24
|
+
const source = await fs.readFile(mainPath)
|
|
25
|
+
const reloadToken = createHash('sha256').update(source).digest('hex')
|
|
26
|
+
const moduleUrl = `${mainPath.href}?quickforgePluginReload=${reloadToken}`
|
|
23
27
|
const module = await import(moduleUrl)
|
|
24
28
|
const factory = module.createPlugin || module.default
|
|
25
29
|
if (typeof factory !== 'function') {
|
|
@@ -41,6 +45,7 @@ export async function loadPlugin(manifest, context = {}) {
|
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
const tools = isPlainObject(plugin.tools) ? plugin.tools : {}
|
|
48
|
+
const dispose = typeof plugin.dispose === 'function' ? plugin.dispose.bind(plugin) : null
|
|
44
49
|
return {
|
|
45
50
|
async callTool(toolName, params = {}, toolContext = {}) {
|
|
46
51
|
const handler = tools[toolName]
|
|
@@ -52,5 +57,8 @@ export async function loadPlugin(manifest, context = {}) {
|
|
|
52
57
|
isError: Boolean(result?.isError),
|
|
53
58
|
}
|
|
54
59
|
},
|
|
60
|
+
async dispose() {
|
|
61
|
+
if (dispose) await dispose()
|
|
62
|
+
},
|
|
55
63
|
}
|
|
56
64
|
}
|
|
@@ -18,6 +18,7 @@ const globalPluginDir = path.join(dataDir, 'plugins')
|
|
|
18
18
|
const legacyGlobalPluginDir = path.join(os.homedir(), '.agents', 'plugins')
|
|
19
19
|
let cachedCatalog = null
|
|
20
20
|
let cachedCatalogKey = null
|
|
21
|
+
let catalogDirty = false
|
|
21
22
|
let refreshPromise = null
|
|
22
23
|
|
|
23
24
|
function catalogKey(projectContext = null) {
|
|
@@ -96,7 +97,7 @@ async function discoverManifests(projectContext) {
|
|
|
96
97
|
return { manifests, errors, roots }
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
async function loadEnabledPlugins(projectContext) {
|
|
100
|
+
async function loadEnabledPlugins(projectContext, previousCatalog = null) {
|
|
100
101
|
const store = await readPluginStore()
|
|
101
102
|
const { manifests, errors, roots } = await discoverManifests(projectContext)
|
|
102
103
|
const plugins = []
|
|
@@ -126,8 +127,16 @@ async function loadEnabledPlugins(projectContext) {
|
|
|
126
127
|
handlers.set(manifest.name, loaded)
|
|
127
128
|
} catch (error) {
|
|
128
129
|
logger.error(`Failed to load plugin ${manifest.name}:`, error)
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
const previousPlugin = previousCatalog?.plugins?.find((plugin) => plugin.name === manifest.name)
|
|
131
|
+
const previousHandler = previousCatalog?.handlers?.get(manifest.name)
|
|
132
|
+
if (previousPlugin?.enabled && previousPlugin.status === 'loaded' && previousHandler) {
|
|
133
|
+
handlers.set(manifest.name, previousHandler)
|
|
134
|
+
entry.status = 'loaded'
|
|
135
|
+
entry.error = error?.message || 'Failed to reload plugin; using previous instance'
|
|
136
|
+
} else {
|
|
137
|
+
entry.status = 'error'
|
|
138
|
+
entry.error = error?.message || 'Failed to load plugin'
|
|
139
|
+
}
|
|
131
140
|
}
|
|
132
141
|
}
|
|
133
142
|
|
|
@@ -138,12 +147,27 @@ async function loadEnabledPlugins(projectContext) {
|
|
|
138
147
|
return { plugins, handlers, errors, roots }
|
|
139
148
|
}
|
|
140
149
|
|
|
150
|
+
async function disposeHandlers(catalog, retainedHandlers = new Set()) {
|
|
151
|
+
if (!catalog?.handlers) return
|
|
152
|
+
await Promise.allSettled(Array.from(catalog.handlers.entries(), async ([name, handler]) => {
|
|
153
|
+
if (retainedHandlers.has(handler) || typeof handler?.dispose !== 'function') return
|
|
154
|
+
try {
|
|
155
|
+
await handler.dispose()
|
|
156
|
+
} catch (error) {
|
|
157
|
+
logger.warn(`Failed to dispose plugin ${name}:`, error)
|
|
158
|
+
}
|
|
159
|
+
}))
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
export async function refreshPlugins(projectContext = null) {
|
|
142
163
|
const key = catalogKey(projectContext)
|
|
143
164
|
if (!refreshPromise) {
|
|
144
|
-
|
|
165
|
+
const previousCatalog = cachedCatalogKey === key ? cachedCatalog : null
|
|
166
|
+
refreshPromise = loadEnabledPlugins(projectContext, previousCatalog).then(async (catalog) => {
|
|
145
167
|
cachedCatalog = catalog
|
|
146
168
|
cachedCatalogKey = key
|
|
169
|
+
catalogDirty = false
|
|
170
|
+
await disposeHandlers(previousCatalog, new Set(catalog.handlers.values()))
|
|
147
171
|
return catalog
|
|
148
172
|
}).finally(() => {
|
|
149
173
|
refreshPromise = null
|
|
@@ -154,7 +178,7 @@ export async function refreshPlugins(projectContext = null) {
|
|
|
154
178
|
|
|
155
179
|
async function getCatalog(projectContext = null) {
|
|
156
180
|
const key = catalogKey(projectContext)
|
|
157
|
-
if (!cachedCatalog || cachedCatalogKey !== key) return refreshPlugins(projectContext)
|
|
181
|
+
if (!cachedCatalog || cachedCatalogKey !== key || catalogDirty) return refreshPlugins(projectContext)
|
|
158
182
|
return cachedCatalog
|
|
159
183
|
}
|
|
160
184
|
|
|
@@ -183,7 +207,7 @@ export async function getEnabledPluginCommandSources(projectContext = null) {
|
|
|
183
207
|
}
|
|
184
208
|
|
|
185
209
|
export async function getPluginStatus(projectContext = null) {
|
|
186
|
-
const catalog = await
|
|
210
|
+
const catalog = await getCatalog(projectContext)
|
|
187
211
|
return {
|
|
188
212
|
searchPaths: catalog.roots,
|
|
189
213
|
errors: catalog.errors,
|
|
@@ -221,8 +245,7 @@ export async function setPluginEnabled(name, enabled) {
|
|
|
221
245
|
next.enabled[name] = enabled === true
|
|
222
246
|
return next
|
|
223
247
|
})
|
|
224
|
-
|
|
225
|
-
cachedCatalogKey = null
|
|
248
|
+
catalogDirty = true
|
|
226
249
|
}
|
|
227
250
|
|
|
228
251
|
export async function setPluginConfig(name, config) {
|
|
@@ -231,8 +254,7 @@ export async function setPluginConfig(name, config) {
|
|
|
231
254
|
next.config[name] = isPlainObject(config) ? config : {}
|
|
232
255
|
return next
|
|
233
256
|
})
|
|
234
|
-
|
|
235
|
-
cachedCatalogKey = null
|
|
257
|
+
catalogDirty = true
|
|
236
258
|
}
|
|
237
259
|
|
|
238
260
|
export async function createPluginToolDefinitions(projectContext = null) {
|
package/server/public-api.mjs
CHANGED
|
@@ -140,6 +140,10 @@ async function waitForQuickForge(options = {}) {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
export async function startQuickForge(options = {}) {
|
|
143
|
+
if (options.networkRuntime) {
|
|
144
|
+
const { registerHostNetworkRuntime } = await import('./network-proxy.mjs')
|
|
145
|
+
registerHostNetworkRuntime(options.networkRuntime)
|
|
146
|
+
}
|
|
143
147
|
const existingHealth = options.reuseExisting === false ? null : await checkQuickForgeHealth(options)
|
|
144
148
|
const url = getQuickForgeUrl(options)
|
|
145
149
|
const healthUrl = getQuickForgeHealthUrl(options)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { streamSimple } from '@earendil-works/pi-ai/compat'
|
|
2
|
+
import { DEFAULT_AI_MAX_RETRIES } from '../ai-provider-options.mjs'
|
|
2
3
|
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
3
4
|
import { readStore } from '../storage.mjs'
|
|
4
5
|
import { logger } from '../utils/logger.mjs'
|
|
@@ -101,6 +102,7 @@ Rules:
|
|
|
101
102
|
maxTokens: 1600,
|
|
102
103
|
temperature: 0,
|
|
103
104
|
reasoning: thinkingLevel === 'off' ? undefined : thinkingLevel,
|
|
105
|
+
maxRetries: DEFAULT_AI_MAX_RETRIES,
|
|
104
106
|
maxRetryDelayMs: 60000,
|
|
105
107
|
},
|
|
106
108
|
)
|
package/server/routes/mcp.mjs
CHANGED
|
@@ -49,11 +49,6 @@ export async function handleMcpApi(req, res, url) {
|
|
|
49
49
|
return
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
if (req.method === 'POST' && url.pathname === '/api/mcp/reconnect') {
|
|
53
|
-
sendJson(res, 200, await refreshMcpAndAgentTools())
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
|
|
57
52
|
if (req.method === 'POST' && parts[0] === 'api' && parts[1] === 'mcp' && parts[2] === 'reconnect' && parts[3]) {
|
|
58
53
|
await reconnectMcpServer(decodeURIComponent(parts[3]))
|
|
59
54
|
sendJson(res, 200, await refreshMcpAndAgentTools())
|
package/server/routes/models.mjs
CHANGED