@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.
Files changed (46) hide show
  1. package/README.md +1 -0
  2. package/bin/quickforge.mjs +2 -0
  3. package/dist/assets/AgentProfilesPage-D7pbg8nm.js +1 -0
  4. package/dist/assets/ChatPanelHost-wfGCaCzD.js +288 -0
  5. package/dist/assets/{PluginsPage-CKyWhlCo.js → PluginsPage-NAGroBm3.js} +1 -1
  6. package/dist/assets/ScheduledTasksPage-Bp0MNfQD.js +2 -0
  7. package/dist/assets/{SettingsWorkspacePage-DwqEnUmX.js → SettingsWorkspacePage-BtJZMIsZ.js} +425 -344
  8. package/dist/assets/SharedConversationPage-Ce3KoVZt.js +1 -0
  9. package/dist/assets/{TerminalDock-B9xKnimU.js → TerminalDock-4eUuXP7X.js} +2 -2
  10. package/dist/assets/WorkspaceInspector-kM3BRPjY.js +13 -0
  11. package/dist/assets/icons-BP8YOS-Z.js +1 -0
  12. package/dist/assets/index-C6k5taeb.js +66 -0
  13. package/dist/assets/index-CzN8NSKC.css +3 -0
  14. package/dist/assets/mcp-servers-dialog-BQbFywVL.js +5 -0
  15. package/dist/assets/{monaco-CPwJMUsl.js → monaco-BTsVCDWS.js} +1 -1
  16. package/dist/assets/{react-vendor-CLbWF1Oy.js → react-vendor-Dr5xvL-e.js} +1 -1
  17. package/dist/assets/{skills-dialog-D18mxNyW.js → skills-dialog-REBeTFSH.js} +1 -1
  18. package/dist/index.html +6 -6
  19. package/package.json +4 -1
  20. package/server/acp/server.mjs +19 -7
  21. package/server/ai-http-logger.mjs +6 -6
  22. package/server/ai-provider-options.mjs +8 -0
  23. package/server/index.mjs +7 -2
  24. package/server/mcp/registry.mjs +54 -14
  25. package/server/network-proxy.mjs +384 -0
  26. package/server/plugins/loader.mjs +9 -1
  27. package/server/plugins/registry.mjs +32 -10
  28. package/server/public-api.mjs +4 -0
  29. package/server/routes/agent-profiles.mjs +2 -0
  30. package/server/routes/mcp.mjs +0 -5
  31. package/server/routes/models.mjs +1 -0
  32. package/server/routes/scheduled-tasks.mjs +74 -32
  33. package/server/routes/system.mjs +26 -0
  34. package/server/routes/workspace.mjs +89 -17
  35. package/server/session-utils.mjs +2 -0
  36. package/server/utils/scheduled-tasks.mjs +23 -10
  37. package/server/utils/workspace.mjs +25 -3
  38. package/dist/assets/AgentProfilesPage-__uY0AvK.js +0 -1
  39. package/dist/assets/ChatPanelHost-DIs_vWFX.js +0 -291
  40. package/dist/assets/ScheduledTasksPage-KtJoeJYt.js +0 -2
  41. package/dist/assets/SharedConversationPage-yujCRsbe.js +0 -1
  42. package/dist/assets/WorkspaceInspector-CE6RD6Ys.js +0 -13
  43. package/dist/assets/icons-pPRMD2tE.js +0 -1
  44. package/dist/assets/index-BSXpUDCq.js +0 -63
  45. package/dist/assets/index-DpO7jEGP.css +0 -3
  46. 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 moduleUrl = `${mainPath.href}?quickforgePluginReload=${Date.now()}`
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
- entry.status = 'error'
130
- entry.error = error?.message || 'Failed to load plugin'
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
- refreshPromise = loadEnabledPlugins(projectContext).then((catalog) => {
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 refreshPlugins(projectContext)
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
- cachedCatalog = null
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
- cachedCatalog = null
235
- cachedCatalogKey = null
257
+ catalogDirty = true
236
258
  }
237
259
 
238
260
  export async function createPluginToolDefinitions(projectContext = null) {
@@ -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
  )
@@ -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())
@@ -35,6 +35,7 @@ async function probeModelConnection(model, apiKeyOverride) {
35
35
  temperature: 0,
36
36
  // Keep reasoning off so thinking-capable models don't require extra tokens.
37
37
  reasoning: undefined,
38
+ maxRetries: 0,
38
39
  maxRetryDelayMs: 30000,
39
40
  },
40
41
  )