@shawnstack/quickforge 1.4.1 → 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.
- package/README.md +12 -12
- package/bin/quickforge.mjs +9 -0
- package/dist/assets/AgentProfilesPage-DUmXUxjA.js +1 -0
- package/dist/assets/ChatPanelHost-Syx0SSLe.js +242 -0
- package/dist/assets/PluginsPage-kiBq0gOT.js +1 -0
- package/dist/assets/ScheduledTasksPage-Dw4-tgp9.js +2 -0
- package/dist/assets/SharedConversationPage-CaE9bNb9.js +1 -0
- package/dist/assets/TerminalDock-BYJcp8Ts.js +2 -0
- package/dist/assets/WorkspaceInspector-Bzmv8Cvi.js +3 -0
- package/dist/assets/WorkspaceReaderDialog-BJo_KEWi.js +1 -0
- package/dist/assets/diff-line-counts-BZoYp5ai.js +10 -0
- package/dist/assets/icons-47L5YLKz.js +1 -0
- package/dist/assets/index-CqfScETb.js +1200 -0
- package/dist/assets/index-DzkBgHZf.css +3 -0
- package/dist/assets/{monaco-evITXh-m.js → monaco-CGq6uVF1.js} +1 -1
- package/dist/assets/{react-vendor-Mthyt1p4.js → react-vendor-DunfCFfp.js} +1 -1
- package/dist/favicon.svg +16 -1
- package/dist/index.html +5 -5
- package/dist/manifest.webmanifest +30 -30
- package/package.json +3 -2
- package/server/acp/server.mjs +921 -0
- package/server/agent-manager.mjs +198 -32
- package/server/agent-profile-files.mjs +179 -0
- package/server/agent-profiles.mjs +59 -5
- package/server/auto-compaction.mjs +82 -39
- package/server/channels/process-channel.mjs +278 -0
- package/server/channels/providers/wechat.mjs +271 -0
- package/server/channels/registry.mjs +58 -0
- package/server/custom-commands.mjs +13 -1
- package/server/frontmatter.mjs +167 -0
- package/server/index.mjs +52 -3
- package/server/project-config.mjs +43 -6
- package/server/routes/agent-profiles.mjs +6 -2
- package/server/routes/agent.mjs +12 -1
- package/server/routes/channels.mjs +145 -0
- package/server/routes/models.mjs +68 -0
- package/server/routes/project.mjs +2 -2
- package/server/routes/scheduled-tasks.mjs +6 -5
- package/server/routes/storage.mjs +4 -2
- package/server/routes/system.mjs +27 -0
- package/server/routes/tools.mjs +17 -6
- package/server/routes/workspace.mjs +138 -0
- package/server/session-utils.mjs +10 -2
- package/server/storage.mjs +29 -2
- package/server/system-prompt.mjs +1 -0
- package/server/tools/definitions.mjs +18 -0
- package/server/tools/index.mjs +83 -0
- package/server/utils/package-update.mjs +156 -0
- package/dist/assets/AgentProfilesPage-CNK5PxA3.js +0 -1
- package/dist/assets/ChatPanelHost-FqPQwwMO.js +0 -217
- package/dist/assets/PluginsPage-BCu1Ept0.js +0 -1
- package/dist/assets/ScheduledTasksPage-Bx04rjui.js +0 -2
- package/dist/assets/SharedConversationPage-55vX9sqe.js +0 -1
- package/dist/assets/TerminalDock-DLN_pLkJ.js +0 -2
- package/dist/assets/WorkspaceInspector-DoemHHnY.js +0 -3
- package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +0 -6
- package/dist/assets/icons-BWtivFsx.js +0 -1
- package/dist/assets/index-CxOHP41X.css +0 -3
- package/dist/assets/index-Dcf73EL8.js +0 -895
|
@@ -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) =>
|
|
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 —
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
package/server/routes/agent.mjs
CHANGED
|
@@ -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,
|
|
@@ -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/
|
|
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
|
+
}
|
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
2
|
-
import { getActiveProject, setActiveProjectPath, readProjectConfig } from '../project-config.mjs'
|
|
2
|
+
import { getActiveProject, setActiveProjectPath, readProjectConfig, getDefaultWorkspaceRoot } from '../project-config.mjs'
|
|
3
3
|
import { listProjectCommands, createCommandFile } from '../custom-commands.mjs'
|
|
4
4
|
import { atomicProjectConfigUpdate } from '../storage.mjs'
|
|
5
5
|
import { getWorkspaceRoot, setWorkspaceRoot } from '../utils/workspace.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
|
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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)
|
package/server/routes/system.mjs
CHANGED
|
@@ -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')
|
package/server/routes/tools.mjs
CHANGED
|
@@ -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
|
-
|
|
30
|
-
if (
|
|
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
|
|
34
|
-
if (
|
|
35
|
-
const error = new Error('
|
|
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
|
|
101
|
+
await assertAccessModeAllowsDirectTool(name)
|
|
91
102
|
|
|
92
103
|
const params = await readJsonBody(req)
|
|
93
104
|
const result = await handler(params || {}, context)
|