@shawnstack/quickforge 1.4.0 → 1.5.0

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 (65) hide show
  1. package/README.md +12 -12
  2. package/bin/quickforge.mjs +9 -0
  3. package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
  4. package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
  5. package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
  6. package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
  7. package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
  8. package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
  9. package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
  10. package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
  11. package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
  12. package/dist/assets/icons-47L5YLKz.js +1 -0
  13. package/dist/assets/index-CqfScETb.js +1200 -0
  14. package/dist/assets/index-DzkBgHZf.css +3 -0
  15. package/dist/assets/{monaco-DG4TcBMc.js → monaco-CGq6uVF1.js} +1 -1
  16. package/dist/assets/{react-vendor-CiCXOLb5.js → react-vendor-DunfCFfp.js} +1 -1
  17. package/dist/favicon.svg +16 -1
  18. package/dist/index.html +5 -5
  19. package/dist/manifest.webmanifest +30 -30
  20. package/package.json +3 -2
  21. package/server/acp/server.mjs +921 -0
  22. package/server/agent-manager.mjs +283 -45
  23. package/server/agent-profile-files.mjs +179 -0
  24. package/server/agent-profiles.mjs +59 -5
  25. package/server/approval-store.mjs +13 -1
  26. package/server/auto-compaction.mjs +111 -112
  27. package/server/channels/process-channel.mjs +278 -0
  28. package/server/channels/providers/wechat.mjs +271 -0
  29. package/server/channels/registry.mjs +58 -0
  30. package/server/context-usage.mjs +108 -0
  31. package/server/custom-commands.mjs +157 -28
  32. package/server/frontmatter.mjs +167 -0
  33. package/server/index.mjs +52 -3
  34. package/server/mcp/registry.mjs +40 -0
  35. package/server/project-config.mjs +43 -6
  36. package/server/routes/agent-profiles.mjs +6 -2
  37. package/server/routes/agent.mjs +13 -2
  38. package/server/routes/channels.mjs +145 -0
  39. package/server/routes/mcp.mjs +7 -1
  40. package/server/routes/models.mjs +68 -0
  41. package/server/routes/project.mjs +34 -4
  42. package/server/routes/scheduled-tasks.mjs +6 -5
  43. package/server/routes/shared-conversation.mjs +1 -1
  44. package/server/routes/storage.mjs +4 -2
  45. package/server/routes/system.mjs +27 -0
  46. package/server/routes/tools.mjs +17 -6
  47. package/server/routes/workspace.mjs +138 -0
  48. package/server/session-utils.mjs +10 -2
  49. package/server/storage.mjs +30 -2
  50. package/server/subagents.mjs +8 -6
  51. package/server/system-prompt.mjs +3 -2
  52. package/server/tools/definitions.mjs +19 -1
  53. package/server/tools/index.mjs +83 -0
  54. package/server/utils/package-update.mjs +156 -0
  55. package/dist/assets/AgentProfilesPage-C79teCgh.js +0 -1
  56. package/dist/assets/ChatPanelHost-BjdIshtX.js +0 -195
  57. package/dist/assets/PluginsPage-Dt7Iiddo.js +0 -1
  58. package/dist/assets/ScheduledTasksPage-C047y3p3.js +0 -2
  59. package/dist/assets/SharedConversationPage-8X8kfztQ.js +0 -1
  60. package/dist/assets/TerminalDock-CEuJNf0m.js +0 -2
  61. package/dist/assets/WorkspaceInspector-BIa5gLVs.js +0 -3
  62. package/dist/assets/WorkspaceReaderDialog-bTeERaGd.js +0 -6
  63. package/dist/assets/icons-Dsc5yL3l.js +0 -1
  64. package/dist/assets/index-CPAWYhzz.css +0 -3
  65. package/dist/assets/index-YTL26wyJ.js +0 -814
@@ -231,6 +231,46 @@ export async function refreshMcpConnections() {
231
231
  return refreshPromise
232
232
  }
233
233
 
234
+ export async function reconnectMcpServer(name) {
235
+ const normalizedName = String(name || '').trim()
236
+ // close any existing connection first (bypasses retry backoff)
237
+ const existing = connections.get(normalizedName)
238
+ if (existing) {
239
+ connections.delete(normalizedName)
240
+ await closeConnection(existing)
241
+ }
242
+ const servers = await readMcpServers()
243
+ const config = servers.find((server) => server.name === normalizedName)
244
+ if (!config) {
245
+ const error = new Error(`MCP server not found: ${normalizedName}`)
246
+ error.statusCode = 404
247
+ throw error
248
+ }
249
+ if (!config.enabled) {
250
+ const error = new Error(`MCP server is disabled: ${normalizedName}`)
251
+ error.statusCode = 400
252
+ throw error
253
+ }
254
+ try {
255
+ const connection = await connectServer(config)
256
+ connections.set(config.name, connection)
257
+ } catch (error) {
258
+ logger.error(`Failed to reconnect MCP server ${config.name}:`, error)
259
+ connections.set(config.name, {
260
+ config,
261
+ client: null,
262
+ transport: null,
263
+ status: 'error',
264
+ error: error?.message || 'Failed to connect MCP server',
265
+ tools: [],
266
+ connectedAt: null,
267
+ stderr: '',
268
+ lastAttemptAt: Date.now(),
269
+ })
270
+ }
271
+ return connections
272
+ }
273
+
234
274
  export async function getMcpStatus() {
235
275
  await refreshMcpConnections()
236
276
  const servers = await readMcpServers()
@@ -13,10 +13,44 @@ export function setDefaultWorkspaceRoot(root) {
13
13
  defaultWorkspaceRoot = path.resolve(root)
14
14
  }
15
15
 
16
+ export function getDefaultWorkspaceRoot() {
17
+ return defaultWorkspaceRoot
18
+ }
19
+
20
+ // Synthetic workspace context for global conversations (no projectId).
21
+ // Gives global chats the same file-tool capabilities as project chats, rooted
22
+ // at the default workspace directory (~/.quickforge/workspace by default). The
23
+ // synthetic `project` object (id 'default') lets workspace/git/terminal REST
24
+ // endpoints and subagents keep working without a real registered project.
25
+ export function defaultGlobalWorkspaceContext() {
26
+ return {
27
+ project: {
28
+ id: 'default',
29
+ name: 'workspace',
30
+ path: defaultWorkspaceRoot,
31
+ lastOpenedAt: '',
32
+ sortOrder: 0,
33
+ skills: [],
34
+ commandDir: '',
35
+ },
36
+ workspaceRoot: defaultWorkspaceRoot,
37
+ }
38
+ }
39
+
16
40
  function projectNameFromPath(dir) {
17
41
  return path.basename(dir) || dir
18
42
  }
19
43
 
44
+ // Compare two project paths for equality in a cross-platform way.
45
+ // On Windows (and other case-insensitive filesystems) drive-letter casing and
46
+ // path separators can differ while pointing at the same directory. Normalize
47
+ // both sides to a resolved lowercase form so the same directory always matches
48
+ // an existing project instead of being re-registered with a new id.
49
+ export function sameProjectPath(a, b) {
50
+ if (!a || !b) return false
51
+ return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase()
52
+ }
53
+
20
54
  function defaultProjectConfig() {
21
55
  return {
22
56
  activeProjectId: null,
@@ -222,7 +256,7 @@ export async function setActiveProjectPath(inputPath) {
222
256
  let project
223
257
 
224
258
  const updated = await atomicProjectConfigUpdate((config) => {
225
- project = config.projects.find((item) => path.resolve(item.path) === resolved)
259
+ project = config.projects.find((item) => sameProjectPath(item.path, resolved))
226
260
  if (!project) {
227
261
  project = {
228
262
  id: randomUUID(),
@@ -264,16 +298,19 @@ export async function initializeActiveProject() {
264
298
  }
265
299
  }
266
300
 
267
- // No project configured — leave workspace unset, user will be prompted to add one.
301
+ // No project configured — fall back to the default workspace root so global
302
+ // conversations still have a working directory and the filesystem browser works.
303
+ setWorkspaceRoot(defaultWorkspaceRoot)
268
304
  }
269
305
 
270
306
  export async function projectContextFromId(projectId) {
271
307
  const config = await readProjectConfig()
272
308
  const project = config.projects.find((item) => item.id === projectId)
273
309
  if (!project) {
274
- const error = new Error('Unknown project')
275
- error.statusCode = 404
276
- throw error
310
+ // Unknown or removed project (e.g. a global conversation's synthetic id)
311
+ // fall back to the default workspace so workspace/git REST endpoints keep
312
+ // working for global conversations.
313
+ return defaultGlobalWorkspaceContext()
277
314
  }
278
315
 
279
316
  await assertDirectory(project.path)
@@ -375,7 +412,7 @@ export async function buildInstructionsPayload(projectId) {
375
412
  name: project.name,
376
413
  root: project.path,
377
414
  }
378
- : null,
415
+ : (defaultWorkspaceRoot ? { name: 'workspace', root: defaultWorkspaceRoot } : null),
379
416
  global: globalInstructions,
380
417
  project: projectInstructions,
381
418
  globalSources: globalInstructionSources,
@@ -3,6 +3,7 @@ import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
3
3
  import { readStore } from '../storage.mjs'
4
4
  import { logger } from '../utils/logger.mjs'
5
5
  import {
6
+ agentProfileSnapshot,
6
7
  createCustomAgentProfile,
7
8
  deleteCustomAgentProfile,
8
9
  getAgentProfile,
@@ -120,7 +121,8 @@ export async function handleAgentProfilesApi(req, res, url) {
120
121
  const parts = url.pathname.split('/').filter(Boolean)
121
122
 
122
123
  if (req.method === 'GET' && url.pathname === '/api/agent-profiles') {
123
- sendJson(res, 200, { agents: await listAgentProfiles({ includeDisabled: true }) })
124
+ const agents = await listAgentProfiles({ includeDisabled: true })
125
+ sendJson(res, 200, { agents: agents.map(agentProfileSnapshot) })
124
126
  return
125
127
  }
126
128
 
@@ -147,13 +149,14 @@ export async function handleAgentProfilesApi(req, res, url) {
147
149
  if (req.method === 'GET') {
148
150
  const agent = await getAgentProfile(id)
149
151
  if (!agent) throw requestError('Agent not found', 404)
150
- sendJson(res, 200, { agent })
152
+ sendJson(res, 200, { agent: agentProfileSnapshot(agent) })
151
153
  return
152
154
  }
153
155
 
154
156
  if (req.method === 'PATCH' || req.method === 'PUT') {
155
157
  const current = await getAgentProfile(id)
156
158
  if (current?.builtin) throw requestError('Built-in agents cannot be modified', 403)
159
+ if (current?.readonly) throw requestError('File-based agents cannot be modified from the API', 403)
157
160
  const body = await readJsonBody(req)
158
161
  sendJson(res, 200, { agent: await updateCustomAgentProfile(id, body || {}) })
159
162
  return
@@ -162,6 +165,7 @@ export async function handleAgentProfilesApi(req, res, url) {
162
165
  if (req.method === 'DELETE') {
163
166
  const current = await getAgentProfile(id)
164
167
  if (current?.builtin) throw requestError('Built-in agents cannot be deleted', 403)
168
+ if (current?.readonly) throw requestError('File-based agents cannot be deleted from the API', 403)
165
169
  await deleteCustomAgentProfile(id)
166
170
  sendJson(res, 200, { ok: true })
167
171
  return
@@ -16,6 +16,7 @@ import {
16
16
  touchSession,
17
17
  listSessions,
18
18
  refreshAllSessionTools,
19
+ updateSessionAccessMode,
19
20
  updateSessionYoloMode,
20
21
  updateSessionModel,
21
22
  updateSessionThinkingLevel,
@@ -83,7 +84,7 @@ export async function handleAgentApi(req, res, url) {
83
84
  error.statusCode = 400
84
85
  throw error
85
86
  }
86
- const result = await runPrompt(sessionId, message, body?.selectedCapabilities)
87
+ const result = await runPrompt(sessionId, message, body?.selectedCapabilities, body?.command)
87
88
  sendJson(res, 200, result)
88
89
  return
89
90
  }
@@ -140,6 +141,8 @@ export async function handleAgentApi(req, res, url) {
140
141
  status: session.status,
141
142
  scope: session.scope,
142
143
  title: session.title,
144
+ accessMode: session.accessMode,
145
+ yoloMode: session.yoloMode,
143
146
  })
144
147
  return
145
148
  }
@@ -151,7 +154,15 @@ export async function handleAgentApi(req, res, url) {
151
154
  return
152
155
  }
153
156
 
154
- // POST /api/agents/:sessionId/yolo-mode — update session YOLO mode
157
+ // POST /api/agents/:sessionId/access-mode — update session Agent access mode
158
+ if (req.method === 'POST' && subPath === 'access-mode') {
159
+ const body = await readJsonBody(req)
160
+ const result = await updateSessionAccessMode(sessionId, body?.accessMode)
161
+ sendJson(res, 200, result)
162
+ return
163
+ }
164
+
165
+ // POST /api/agents/:sessionId/yolo-mode — legacy compatibility for old clients
155
166
  if (req.method === 'POST' && subPath === 'yolo-mode') {
156
167
  const body = await readJsonBody(req)
157
168
  const result = await updateSessionYoloMode(sessionId, body?.yoloMode === true)
@@ -0,0 +1,145 @@
1
+ import { sendJson, decodeSegment, readJsonBody } from '../utils/response.mjs'
2
+ import {
3
+ channelEvents,
4
+ getChannelStatus,
5
+ listChannels,
6
+ restartChannel,
7
+ runChannelAction,
8
+ startChannel,
9
+ stopChannel,
10
+ } from '../channels/registry.mjs'
11
+
12
+ function assertLocal(context) {
13
+ if (!context.isLocalRequest) {
14
+ const error = new Error('Channel management is only allowed from this computer')
15
+ error.statusCode = 403
16
+ throw error
17
+ }
18
+ }
19
+
20
+ function assertActionHeader(req) {
21
+ if (req.headers['x-quickforge-action'] !== 'channel-action') {
22
+ const error = new Error('Forbidden action')
23
+ error.statusCode = 403
24
+ throw error
25
+ }
26
+ }
27
+
28
+ async function readActionOptions(req) {
29
+ if (!['POST', 'PUT', 'PATCH'].includes(req.method || '')) return {}
30
+ const contentType = String(req.headers['content-type'] || '')
31
+ if (!contentType.toLowerCase().includes('application/json')) return {}
32
+ return await readJsonBody(req, 64 * 1024) || {}
33
+ }
34
+
35
+ export async function handleChannelsApi(req, res, url, context = {}) {
36
+ const pathname = url.pathname
37
+ const parts = pathname.split('/').filter(Boolean)
38
+
39
+ if (req.method === 'GET' && pathname === '/api/channels') {
40
+ sendJson(res, 200, { channels: listChannels() })
41
+ return
42
+ }
43
+
44
+ if (req.method === 'GET' && pathname === '/api/channels/events') {
45
+ handleChannelEvents(req, res)
46
+ return
47
+ }
48
+
49
+ if (parts.length < 3 || parts[0] !== 'api' || parts[1] !== 'channels') {
50
+ const error = new Error('Not found')
51
+ error.statusCode = 404
52
+ throw error
53
+ }
54
+
55
+ const channelId = decodeSegment(parts[2])
56
+ const subPath = parts.slice(3).map(decodeSegment)
57
+
58
+ if (req.method === 'GET' && subPath.length === 0) {
59
+ sendJson(res, 200, getChannelStatus(channelId))
60
+ return
61
+ }
62
+
63
+ if (req.method === 'GET' && subPath[0] === 'status') {
64
+ sendJson(res, 200, getChannelStatus(channelId))
65
+ return
66
+ }
67
+
68
+ assertLocal(context)
69
+
70
+ if (req.method === 'POST' && subPath[0] === 'start') {
71
+ assertActionHeader(req)
72
+ sendJson(res, 202, await startChannel(channelId, await readActionOptions(req)))
73
+ return
74
+ }
75
+
76
+ if (req.method === 'POST' && subPath[0] === 'stop') {
77
+ assertActionHeader(req)
78
+ sendJson(res, 202, await stopChannel(channelId))
79
+ return
80
+ }
81
+
82
+ if (req.method === 'POST' && subPath[0] === 'restart') {
83
+ assertActionHeader(req)
84
+ sendJson(res, 202, await restartChannel(channelId, await readActionOptions(req)))
85
+ return
86
+ }
87
+
88
+ if (req.method === 'POST' && subPath[0] === 'actions' && subPath[1]) {
89
+ assertActionHeader(req)
90
+ sendJson(res, 202, await runChannelAction(channelId, subPath[1], await readActionOptions(req)))
91
+ return
92
+ }
93
+
94
+ const error = new Error('Not found')
95
+ error.statusCode = 404
96
+ throw error
97
+ }
98
+
99
+ function handleChannelEvents(req, res) {
100
+ res.writeHead(200, {
101
+ 'content-type': 'text/event-stream',
102
+ 'cache-control': 'no-cache, no-transform',
103
+ connection: 'keep-alive',
104
+ 'x-accel-buffering': 'no',
105
+ })
106
+
107
+ writeSseEvent(res, 'snapshot', { type: 'snapshot', channels: listChannels(), timestamp: new Date().toISOString() })
108
+
109
+ const keepAlive = setInterval(() => {
110
+ try {
111
+ res.write(': ping\n\n')
112
+ } catch {
113
+ cleanup()
114
+ }
115
+ }, 15000)
116
+
117
+ const onChannelEvent = (event) => {
118
+ try {
119
+ writeSseEvent(res, event.type || 'channel_event', event)
120
+ } catch {
121
+ cleanup()
122
+ }
123
+ }
124
+
125
+ const cleanup = () => {
126
+ clearInterval(keepAlive)
127
+ channelEvents.removeListener('channel_event', onChannelEvent)
128
+ if (!res.writableEnded) res.end()
129
+ }
130
+
131
+ channelEvents.on('channel_event', onChannelEvent)
132
+ req.on('close', cleanup)
133
+ req.on('error', cleanup)
134
+ res.on('error', cleanup)
135
+ }
136
+
137
+ function writeSseEvent(res, event, data) {
138
+ const payload = typeof data === 'string' ? data : JSON.stringify(data)
139
+ const lines = payload.split('\n')
140
+ res.write(`event: ${event}\n`)
141
+ for (const line of lines) {
142
+ res.write(`data: ${line}\n`)
143
+ }
144
+ res.write('\n')
145
+ }
@@ -1,7 +1,7 @@
1
1
  import { sendJson, readJsonBody } from '../utils/response.mjs'
2
2
  import { refreshAllSessionTools } from '../agent-manager.mjs'
3
3
  import { deleteMcpServer, normalizeMcpServers, readMcpServers, setMcpServerEnabled, upsertMcpServer, writeMcpServers } from '../mcp/config.mjs'
4
- import { getMcpStatus, refreshMcpConnections } from '../mcp/registry.mjs'
4
+ import { getMcpStatus, reconnectMcpServer, refreshMcpConnections } from '../mcp/registry.mjs'
5
5
 
6
6
  async function refreshMcpAndAgentTools() {
7
7
  await refreshMcpConnections()
@@ -54,6 +54,12 @@ export async function handleMcpApi(req, res, url) {
54
54
  return
55
55
  }
56
56
 
57
+ if (req.method === 'POST' && parts[0] === 'api' && parts[1] === 'mcp' && parts[2] === 'reconnect' && parts[3]) {
58
+ await reconnectMcpServer(decodeURIComponent(parts[3]))
59
+ sendJson(res, 200, await refreshMcpAndAgentTools())
60
+ return
61
+ }
62
+
57
63
  if (req.method === 'DELETE' && parts[0] === 'api' && parts[1] === 'mcp' && parts[2] === 'servers' && parts[3]) {
58
64
  await deleteMcpServer(decodeURIComponent(parts[3]))
59
65
  sendJson(res, 200, await refreshMcpAndAgentTools())
@@ -0,0 +1,68 @@
1
+ import { streamSimple } from '@earendil-works/pi-ai'
2
+ import { sendJson, readJsonBody } from '../utils/response.mjs'
3
+ import { readStore } from '../storage.mjs'
4
+ import { logger } from '../utils/logger.mjs'
5
+
6
+ function requestError(message, statusCode = 400) {
7
+ const error = new Error(message)
8
+ error.statusCode = statusCode
9
+ return error
10
+ }
11
+
12
+ async function getApiKey(provider) {
13
+ try {
14
+ const keys = await readStore('provider-keys')
15
+ return keys?.[provider] || undefined
16
+ } catch {
17
+ return undefined
18
+ }
19
+ }
20
+
21
+ // Send a minimal one-token request to verify the endpoint is reachable and the
22
+ // API key is valid. Returns { ok: true } on success; throws on failure.
23
+ async function probeModelConnection(model, apiKeyOverride) {
24
+ const apiKey = apiKeyOverride || (await getApiKey(model?.provider))
25
+ const stream = streamSimple(
26
+ model,
27
+ {
28
+ systemPrompt: 'You are a connectivity test. Reply with a single word.',
29
+ messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
30
+ tools: [],
31
+ },
32
+ {
33
+ apiKey,
34
+ maxTokens: 16,
35
+ temperature: 0,
36
+ // Keep reasoning off so thinking-capable models don't require extra tokens.
37
+ reasoning: undefined,
38
+ maxRetryDelayMs: 30000,
39
+ },
40
+ )
41
+ await stream.result()
42
+ return { ok: true }
43
+ }
44
+
45
+ export async function handleModelsApi(req, res, url) {
46
+ if (req.method === 'POST' && url.pathname === '/api/models/test-connection') {
47
+ const body = await readJsonBody(req)
48
+ const model = body?.model
49
+ const apiKeyOverride =
50
+ typeof body?.apiKey === 'string' && body.apiKey.trim() ? body.apiKey.trim() : undefined
51
+
52
+ if (!model || !model.id || !model.baseUrl) {
53
+ throw requestError('model and baseUrl are required')
54
+ }
55
+
56
+ try {
57
+ const result = await probeModelConnection(model, apiKeyOverride)
58
+ sendJson(res, 200, result)
59
+ } catch (error) {
60
+ logger.warn('Model connection test failed:', error?.message || error)
61
+ // Return 200 with { ok:false } so the client can parse success/failure uniformly.
62
+ sendJson(res, 200, { ok: false, error: error?.message || String(error) })
63
+ }
64
+ return
65
+ }
66
+
67
+ throw requestError('Not found', 404)
68
+ }
@@ -1,6 +1,6 @@
1
1
  import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
2
- import { getActiveProject, setActiveProjectPath, readProjectConfig } from '../project-config.mjs'
3
- import { listProjectCommands } from '../custom-commands.mjs'
2
+ import { getActiveProject, setActiveProjectPath, readProjectConfig, getDefaultWorkspaceRoot } from '../project-config.mjs'
3
+ import { listProjectCommands, createCommandFile } from '../custom-commands.mjs'
4
4
  import { atomicProjectConfigUpdate } from '../storage.mjs'
5
5
  import { getWorkspaceRoot, setWorkspaceRoot } from '../utils/workspace.mjs'
6
6
  import { selectDirectoryDialog, openPathInFileManager } from '../utils/platform.mjs'
@@ -11,7 +11,7 @@ export async function handleProjectApi(req, res, url) {
11
11
 
12
12
  if (req.method === 'GET' && url.pathname === '/api/project') {
13
13
  const sorted = [...config.projects].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
14
- sendJson(res, 200, { project: getActiveProject(config), projects: sorted, workspaceRoot: getWorkspaceRoot() })
14
+ sendJson(res, 200, { project: getActiveProject(config), projects: sorted, workspaceRoot: getWorkspaceRoot(), defaultWorkspaceRoot: getDefaultWorkspaceRoot() })
15
15
  return
16
16
  }
17
17
 
@@ -20,7 +20,7 @@ export async function handleProjectApi(req, res, url) {
20
20
  const project = projectId
21
21
  ? config.projects.find((item) => item.id === projectId)
22
22
  : getActiveProject(config)
23
- const commands = project?.path ? await listProjectCommands(project.path, project.commandDir) : []
23
+ const commands = await listProjectCommands(project?.path, project?.commandDir)
24
24
  sendJson(res, 200, {
25
25
  commands: commands.map((command) => ({
26
26
  name: command.name,
@@ -29,6 +29,7 @@ export async function handleProjectApi(req, res, url) {
29
29
  allowEdit: command.allowEdit,
30
30
  allowCommands: command.allowCommands,
31
31
  relativePath: command.relativePath,
32
+ filePath: command.filePath,
32
33
  source: command.source,
33
34
  pluginName: command.pluginName,
34
35
  })),
@@ -107,6 +108,35 @@ export async function handleProjectApi(req, res, url) {
107
108
  return
108
109
  }
109
110
 
111
+ if (req.method === 'POST' && url.pathname === '/api/project/open-path') {
112
+ const body = await readJsonBody(req)
113
+ const active = body?.projectId
114
+ ? config.projects.find((project) => project.id === body.projectId) ?? getActiveProject(config)
115
+ : getActiveProject(config)
116
+ const target = String(body?.path || '')
117
+ const resolved = target && path.isAbsolute(target)
118
+ ? path.resolve(target)
119
+ : path.resolve(active?.path || '', target)
120
+ await openPathInFileManager(resolved)
121
+ sendJson(res, 200, { ok: true })
122
+ return
123
+ }
124
+
125
+ if (req.method === 'POST' && url.pathname === '/api/project/command') {
126
+ const body = await readJsonBody(req)
127
+ const active = body?.projectId
128
+ ? config.projects.find((project) => project.id === body.projectId) ?? getActiveProject(config)
129
+ : getActiveProject(config)
130
+ if (!active?.path) {
131
+ const error = new Error('No active project')
132
+ error.statusCode = 400
133
+ throw error
134
+ }
135
+ const result = await createCommandFile(active.path, body?.name)
136
+ sendJson(res, 200, result)
137
+ return
138
+ }
139
+
110
140
  if (req.method === 'DELETE' && url.pathname.startsWith('/api/project/')) {
111
141
  const id = decodeSegment(url.pathname.split('/').filter(Boolean)[2])
112
142
  const updated = await atomicProjectConfigUpdate((cfg) => {
@@ -585,11 +585,7 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
585
585
  let sessionId = `scheduled-${task.id}-${Date.now().toString(36)}`
586
586
  let executionAgent = null
587
587
  let agentWarning = null
588
- if (task.agentId) {
589
- executionAgent = await getAgentProfile(task.agentId)
590
- if (!executionAgent) agentWarning = `Configured agent not found: ${task.agentId}`
591
- }
592
- const agentSnapshot = executionAgent ? agentProfileSnapshot(executionAgent) : null
588
+ let agentSnapshot = null
593
589
 
594
590
  let started = false
595
591
  await updateTask(task.id, (current) => {
@@ -630,6 +626,11 @@ async function executeTask(task, trigger = 'schedule', onStarted) {
630
626
 
631
627
  try {
632
628
  const executionProject = await resolveExecutionProject(task)
629
+ if (task.agentId) {
630
+ executionAgent = await getAgentProfile(task.agentId, { projectId: executionProject?.id || null, workspaceRoot: executionProject?.path })
631
+ if (!executionAgent) agentWarning = `Configured agent not found: ${task.agentId}`
632
+ }
633
+ agentSnapshot = executionAgent ? agentProfileSnapshot(executionAgent) : null
633
634
  const settings = await readStore('settings')
634
635
  const yoloMode = settings?.['yolo-mode'] === true || settings?.['yolo-mode'] === 'true'
635
636
 
@@ -363,7 +363,7 @@ export async function handleSharedConversationApi(req, res, url) {
363
363
  assertOperate(record)
364
364
  const body = await readJsonBody(req)
365
365
  await restoreAgent(record.sessionId)
366
- const result = await runPrompt(record.sessionId, messageFromBody(body, record, req))
366
+ const result = await runPrompt(record.sessionId, messageFromBody(body, record, req), [], body?.command)
367
367
  sendJson(res, 200, result)
368
368
  return
369
369
  }
@@ -5,6 +5,7 @@ import { directorySize } from '../utils/workspace.mjs'
5
5
 
6
6
  const metadataIndexCache = new Map()
7
7
  const MAX_METADATA_INDEX_CACHE_ENTRIES = 50
8
+ const METADATA_INDEX_CACHE_TTL_MS = 1000
8
9
 
9
10
  function metadataIndexCacheKey({ scope, projectId, indexName, direction }) {
10
11
  return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction })
@@ -47,7 +48,8 @@ async function readIndexedValues(store, indexName, direction, scope, projectId)
47
48
  const revision = getStoreRevision(store)
48
49
  const key = metadataIndexCacheKey({ scope, projectId, indexName, direction })
49
50
  const cached = metadataIndexCache.get(key)
50
- if (cached && cached.revision === revision) return cached.values
51
+ const now = Date.now()
52
+ if (cached && cached.revision === revision && now - cached.cachedAt < METADATA_INDEX_CACHE_TTL_MS) return cached.values
51
53
 
52
54
  const data = scope
53
55
  ? await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
@@ -59,7 +61,7 @@ async function readIndexedValues(store, indexName, direction, scope, projectId)
59
61
  direction,
60
62
  )
61
63
 
62
- metadataIndexCache.set(key, { revision, values })
64
+ metadataIndexCache.set(key, { revision, values, cachedAt: now })
63
65
  if (metadataIndexCache.size > MAX_METADATA_INDEX_CACHE_ENTRIES) {
64
66
  const firstKey = metadataIndexCache.keys().next().value
65
67
  if (firstKey) metadataIndexCache.delete(firstKey)
@@ -2,6 +2,33 @@ import { sendJson, readJsonBody } from '../utils/response.mjs'
2
2
  import { getLanUrls } from '../utils/network.mjs'
3
3
 
4
4
  export async function handleSystemApi(req, res, url, context) {
5
+ if (req.method === 'GET' && url.pathname === '/api/system/about') {
6
+ sendJson(res, 200, await context.getPackageInfo())
7
+ return
8
+ }
9
+
10
+ if (req.method === 'GET' && url.pathname === '/api/system/update/check') {
11
+ sendJson(res, 200, await context.checkForUpdates())
12
+ return
13
+ }
14
+
15
+ if (req.method === 'POST' && url.pathname === '/api/system/update') {
16
+ if (!context.isLocalRequest) {
17
+ const error = new Error('Update is only allowed from this computer')
18
+ error.statusCode = 403
19
+ throw error
20
+ }
21
+
22
+ if (req.headers['x-quickforge-action'] !== 'update') {
23
+ const error = new Error('Forbidden action')
24
+ error.statusCode = 403
25
+ throw error
26
+ }
27
+
28
+ sendJson(res, 200, await context.updateQuickForge())
29
+ return
30
+ }
31
+
5
32
  if (req.method === 'POST' && url.pathname === '/api/system/restart') {
6
33
  if (req.headers['x-quickforge-action'] !== 'restart') {
7
34
  const error = new Error('Forbidden action')
@@ -4,6 +4,7 @@ import { toolHandlers, loadSkillToolContext } from '../tools/index.mjs'
4
4
  import { createSkillTools, workspaceTools } from '../tools/definitions.mjs'
5
5
  import { createMcpToolDefinitions } from '../mcp/registry.mjs'
6
6
  import { callPluginTool, createPluginToolDefinitions, isPluginToolName } from '../plugins/registry.mjs'
7
+ import { safeReadTools } from '../approval-store.mjs'
7
8
  import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
8
9
 
9
10
  const directRouteDisabledTools = new Set(['run_subagent'])
@@ -26,13 +27,22 @@ export async function handleGetTools(_req, res) {
26
27
 
27
28
  const workspaceToolNames = new Set(workspaceTools.map((tool) => tool.name))
28
29
 
29
- async function assertYoloEnabledForTool(name) {
30
- if (!workspaceToolNames.has(name)) return
30
+ function normalizeAccessMode(value, fallback = 'default') {
31
+ if (value === 'default' || value === 'full-access') return value
32
+ if (value === true || value === 'true') return 'full-access'
33
+ if (value === false || value === 'false') return 'default'
34
+ if (fallback !== value) return normalizeAccessMode(fallback, 'default')
35
+ return 'default'
36
+ }
37
+
38
+ async function assertAccessModeAllowsDirectTool(name) {
39
+ const protectedTool = workspaceToolNames.has(name) || isPluginToolName(name)
40
+ if (!protectedTool || safeReadTools.has(name)) return
31
41
 
32
42
  const settings = await readStore('settings')
33
- const yoloMode = settings?.['yolo-mode'] === true || settings?.['yolo-mode'] === 'true'
34
- if (!yoloMode) {
35
- const error = new Error('YOLO mode is disabled. Enable it to use this tool.')
43
+ const accessMode = normalizeAccessMode(settings?.['agent-access-mode'], settings?.['yolo-mode'])
44
+ if (accessMode !== 'full-access') {
45
+ const error = new Error('Full access permission is required to execute this tool directly.')
36
46
  error.statusCode = 403
37
47
  throw error
38
48
  }
@@ -74,6 +84,7 @@ export async function handleToolApi(req, res, url) {
74
84
  }
75
85
 
76
86
  if (isPluginToolName(name)) {
87
+ await assertAccessModeAllowsDirectTool(name)
77
88
  const params = await readJsonBody(req)
78
89
  const result = await callPluginTool(name, params || {}, context)
79
90
  sendJson(res, 200, result)
@@ -87,7 +98,7 @@ export async function handleToolApi(req, res, url) {
87
98
  throw error
88
99
  }
89
100
 
90
- await assertYoloEnabledForTool(name)
101
+ await assertAccessModeAllowsDirectTool(name)
91
102
 
92
103
  const params = await readJsonBody(req)
93
104
  const result = await handler(params || {}, context)