@shawnstack/quickforge 1.4.1 → 1.5.1
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-BIwd5Nzg.js +1 -0
- package/dist/assets/ChatPanelHost-De-DMjx5.js +242 -0
- package/dist/assets/PluginsPage-kRzB5k8J.js +1 -0
- package/dist/assets/ScheduledTasksPage-ZnjohaPS.js +2 -0
- package/dist/assets/SharedConversationPage-EQdZgWCM.js +1 -0
- package/dist/assets/TerminalDock-P2pJH_tx.js +2 -0
- package/dist/assets/WorkspaceInspector-CkLAqYQ6.js +3 -0
- package/dist/assets/WorkspaceReaderDialog-BwzZ8Tgv.js +1 -0
- package/dist/assets/diff-line-counts-CeZC7b0z.js +10 -0
- package/dist/assets/icons-DJqt-rnw.js +1 -0
- package/dist/assets/index-CcGy4TXo.js +1354 -0
- package/dist/assets/index-DuTUuAMk.css +3 -0
- package/dist/assets/{monaco-evITXh-m.js → monaco-CNEfYIy1.js} +1 -1
- package/dist/assets/{react-vendor-Mthyt1p4.js → react-vendor-CZCcjpSR.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 +200 -34
- 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 +142 -20
- 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 +86 -0
- package/server/utils/package-update.mjs +156 -0
- package/server/utils/workspace.mjs +1 -1
- 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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
function leadingIndent(line) {
|
|
2
|
+
const match = String(line || '').match(/^\s*/)
|
|
3
|
+
return match ? match[0].length : 0
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function stripInlineComment(value) {
|
|
7
|
+
const trimmed = String(value ?? '').trim()
|
|
8
|
+
if (trimmed.startsWith('"') || trimmed.startsWith("'")) return trimmed
|
|
9
|
+
const index = trimmed.indexOf(' #')
|
|
10
|
+
return index >= 0 ? trimmed.slice(0, index).trimEnd() : trimmed
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseYamlScalar(value, options = {}) {
|
|
14
|
+
const trimmed = stripInlineComment(value)
|
|
15
|
+
if (!trimmed) return ''
|
|
16
|
+
if (options.booleans !== false) {
|
|
17
|
+
const normalized = trimmed.toLowerCase()
|
|
18
|
+
if (normalized === 'true') return true
|
|
19
|
+
if (normalized === 'false') return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
24
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
25
|
+
) {
|
|
26
|
+
return trimmed
|
|
27
|
+
.slice(1, -1)
|
|
28
|
+
.replace(/\\"/g, '"')
|
|
29
|
+
.replace(/\\'/g, "'")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return trimmed
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectIndentedBlock(lines, startIndex, parentIndent) {
|
|
36
|
+
const block = []
|
|
37
|
+
let index = startIndex
|
|
38
|
+
while (index < lines.length) {
|
|
39
|
+
const line = lines[index]
|
|
40
|
+
if (!line.trim()) {
|
|
41
|
+
block.push(line)
|
|
42
|
+
index++
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (leadingIndent(line) <= parentIndent) break
|
|
46
|
+
block.push(line)
|
|
47
|
+
index++
|
|
48
|
+
}
|
|
49
|
+
return { block, nextIndex: index }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseBlockScalar(lines, style) {
|
|
53
|
+
const nonEmpty = lines.filter((line) => line.trim())
|
|
54
|
+
const minIndent = nonEmpty.length
|
|
55
|
+
? Math.min(...nonEmpty.map((line) => leadingIndent(line)))
|
|
56
|
+
: 0
|
|
57
|
+
const unindented = lines.map((line) => line.slice(Math.min(minIndent, line.length)))
|
|
58
|
+
return style === '>'
|
|
59
|
+
? unindented.join(' ').replace(/\s+/g, ' ').trim()
|
|
60
|
+
: unindented.join('\n').trim()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseSimpleYamlMap(text, options = {}) {
|
|
64
|
+
const result = {}
|
|
65
|
+
const lines = String(text || '').split(/\r?\n/)
|
|
66
|
+
let index = 0
|
|
67
|
+
|
|
68
|
+
while (index < lines.length) {
|
|
69
|
+
const line = lines[index]
|
|
70
|
+
const trimmed = line.trim()
|
|
71
|
+
if (!trimmed || trimmed.startsWith('#') || leadingIndent(line) > 0) {
|
|
72
|
+
index++
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const match = line.match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
|
|
77
|
+
if (!match) {
|
|
78
|
+
index++
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [, key, rawValue = ''] = match
|
|
83
|
+
const value = rawValue.trim()
|
|
84
|
+
|
|
85
|
+
if (value === '|' || value === '>') {
|
|
86
|
+
const { block, nextIndex } = collectIndentedBlock(lines, index + 1, 0)
|
|
87
|
+
result[key] = parseBlockScalar(block, value)
|
|
88
|
+
index = nextIndex
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (value) {
|
|
93
|
+
result[key] = parseYamlScalar(value, options)
|
|
94
|
+
index++
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const nested = {}
|
|
99
|
+
let nestedIndex = index + 1
|
|
100
|
+
while (nestedIndex < lines.length) {
|
|
101
|
+
const nestedLine = lines[nestedIndex]
|
|
102
|
+
const nestedTrimmed = nestedLine.trim()
|
|
103
|
+
if (!nestedTrimmed || nestedTrimmed.startsWith('#')) {
|
|
104
|
+
nestedIndex++
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const indent = leadingIndent(nestedLine)
|
|
109
|
+
if (indent <= 0) break
|
|
110
|
+
|
|
111
|
+
const nestedMatch = nestedLine.slice(indent).match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
|
|
112
|
+
if (!nestedMatch) {
|
|
113
|
+
nestedIndex++
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [, nestedKey, nestedRawValue = ''] = nestedMatch
|
|
118
|
+
nested[nestedKey] = parseYamlScalar(nestedRawValue.trim(), options)
|
|
119
|
+
nestedIndex++
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
result[key] = Object.keys(nested).length ? nested : ''
|
|
123
|
+
index = Object.keys(nested).length ? nestedIndex : index + 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseFrontmatter(text, options = {}) {
|
|
130
|
+
const normalized = String(text || '').replace(/^\uFEFF/, '')
|
|
131
|
+
const match = normalized.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/)
|
|
132
|
+
if (!match) {
|
|
133
|
+
return options.requireFrontmatter ? null : { metadata: {}, frontmatter: '', body: normalized.trim() }
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
metadata: parseSimpleYamlMap(match[1], options),
|
|
137
|
+
frontmatter: match[1],
|
|
138
|
+
body: match[2].trim(),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function firstString(...values) {
|
|
143
|
+
for (const value of values) {
|
|
144
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
145
|
+
}
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function firstOptionalBoolean(...values) {
|
|
150
|
+
for (const value of values) {
|
|
151
|
+
if (value === true || value === false) return value
|
|
152
|
+
if (typeof value === 'string') {
|
|
153
|
+
const normalized = value.trim().toLowerCase()
|
|
154
|
+
if (normalized === 'true') return true
|
|
155
|
+
if (normalized === 'false') return false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function splitDelimitedList(value) {
|
|
162
|
+
if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean)
|
|
163
|
+
return String(value || '')
|
|
164
|
+
.split(',')
|
|
165
|
+
.map((item) => item.trim())
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
}
|
package/server/index.mjs
CHANGED
|
@@ -27,13 +27,17 @@ import { handleMcpApi } from './routes/mcp.mjs'
|
|
|
27
27
|
import { handlePluginsApi } from './routes/plugins.mjs'
|
|
28
28
|
import { handleWorkspaceApi, handleGitApi } from './routes/workspace.mjs'
|
|
29
29
|
import { handleTerminalApi, handleTerminalUpgrade } from './routes/terminal.mjs'
|
|
30
|
+
import { handleChannelsApi } from './routes/channels.mjs'
|
|
31
|
+
import { handleModelsApi } from './routes/models.mjs'
|
|
30
32
|
import { serveStatic } from './routes/static.mjs'
|
|
31
33
|
import { logger, flushLogger } from './utils/logger.mjs'
|
|
34
|
+
import { getPackageInfo, checkForUpdates, installLatestVersion } from './utils/package-update.mjs'
|
|
32
35
|
import { installAiHttpLogger } from './ai-http-logger.mjs'
|
|
33
36
|
import { isLoopbackAddress, getLanUrls } from './utils/network.mjs'
|
|
34
37
|
import { parseCookies } from './share-store.mjs'
|
|
35
38
|
import { lanAccessCookieName, verifyLanAccessToken } from './lan-access-store.mjs'
|
|
36
39
|
import { shutdown as shutdownAgentManager, resetStaleTaskStatuses } from './agent-manager.mjs'
|
|
40
|
+
import { initializeChannels, shutdownChannels } from './channels/registry.mjs'
|
|
37
41
|
import { shutdownMcpConnections } from './mcp/registry.mjs'
|
|
38
42
|
import { shutdownTerminalSessions } from './terminal/terminal-manager.mjs'
|
|
39
43
|
|
|
@@ -54,8 +58,9 @@ if (!['127.0.0.1', 'localhost'].includes(host) && process.env.QUICKFORGE_ALLOW_R
|
|
|
54
58
|
const port = Number(process.env.QUICKFORGE_PORT || (isDev ? 32176 : 5176))
|
|
55
59
|
const vitePort = Number(process.env.QUICKFORGE_VITE_PORT || 5176)
|
|
56
60
|
let restartInProgress = false
|
|
61
|
+
let updateInProgress = false
|
|
57
62
|
|
|
58
|
-
setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR ||
|
|
63
|
+
setDefaultWorkspaceRoot(process.env.QUICKFORGE_WORKSPACE_DIR || path.join(dataDir, 'workspace'))
|
|
59
64
|
installAiHttpLogger()
|
|
60
65
|
|
|
61
66
|
function getRestartSupport() {
|
|
@@ -139,6 +144,7 @@ async function performRestart() {
|
|
|
139
144
|
stopVite()
|
|
140
145
|
await shutdownAgentManager()
|
|
141
146
|
await shutdownMcpConnections()
|
|
147
|
+
await shutdownChannels()
|
|
142
148
|
shutdownTerminalSessions()
|
|
143
149
|
await closeHttpServer()
|
|
144
150
|
process.exit(0)
|
|
@@ -169,6 +175,29 @@ async function requestRestart() {
|
|
|
169
175
|
return { ok: true, restarting: true, bootId }
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
async function updateQuickForge() {
|
|
179
|
+
if (updateInProgress) {
|
|
180
|
+
const error = new Error('Update already in progress')
|
|
181
|
+
error.statusCode = 423
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
updateInProgress = true
|
|
186
|
+
try {
|
|
187
|
+
const update = await checkForUpdates(projectRoot)
|
|
188
|
+
if (!update.updateAvailable) {
|
|
189
|
+
return { ...update, ok: true, updated: false }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
logger.info(`Updating QuickForge from ${update.currentVersion} to ${update.latestVersion}.`)
|
|
193
|
+
await installLatestVersion(update.name, { cwd: projectRoot })
|
|
194
|
+
logger.info('QuickForge update completed.')
|
|
195
|
+
return { ...update, ok: true, updated: true }
|
|
196
|
+
} finally {
|
|
197
|
+
updateInProgress = false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
// --- Route dispatching ---
|
|
173
202
|
async function handleApi(req, res, url) {
|
|
174
203
|
const pathname = url.pathname
|
|
@@ -216,6 +245,12 @@ async function handleApi(req, res, url) {
|
|
|
216
245
|
return
|
|
217
246
|
}
|
|
218
247
|
|
|
248
|
+
// Custom model management (connection test)
|
|
249
|
+
if (pathname === '/api/models/test-connection') {
|
|
250
|
+
await handleModelsApi(req, res, url)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
219
254
|
// Skills
|
|
220
255
|
if (pathname === '/api/skills' || pathname.startsWith('/api/skills/')) {
|
|
221
256
|
await handleSkillsApi(req, res, url)
|
|
@@ -228,6 +263,14 @@ async function handleApi(req, res, url) {
|
|
|
228
263
|
return
|
|
229
264
|
}
|
|
230
265
|
|
|
266
|
+
// Channels
|
|
267
|
+
if (pathname === '/api/channels' || pathname.startsWith('/api/channels/')) {
|
|
268
|
+
await handleChannelsApi(req, res, url, {
|
|
269
|
+
isLocalRequest: isLoopbackAddress(req.socket.remoteAddress),
|
|
270
|
+
})
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
231
274
|
// Plugins
|
|
232
275
|
if (pathname === '/api/plugins' || pathname.startsWith('/api/plugins/')) {
|
|
233
276
|
await handlePluginsApi(req, res, url)
|
|
@@ -241,7 +284,7 @@ async function handleApi(req, res, url) {
|
|
|
241
284
|
}
|
|
242
285
|
|
|
243
286
|
// Project workspace inspector routes
|
|
244
|
-
if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path') {
|
|
287
|
+
if (pathname === '/api/workspace/tree' || pathname === '/api/workspace/file' || pathname === '/api/workspace/resolve-path' || pathname.startsWith('/api/workspace/preview/')) {
|
|
245
288
|
await handleWorkspaceApi(req, res, url)
|
|
246
289
|
return
|
|
247
290
|
}
|
|
@@ -288,10 +331,14 @@ async function handleApi(req, res, url) {
|
|
|
288
331
|
}
|
|
289
332
|
|
|
290
333
|
// System routes
|
|
291
|
-
if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network' || pathname === '/api/system/terminal-shell') {
|
|
334
|
+
if (pathname === '/api/system/status' || pathname === '/api/system/restart' || pathname === '/api/system/network' || pathname === '/api/system/terminal-shell' || pathname === '/api/system/about' || pathname === '/api/system/update/check' || pathname === '/api/system/update') {
|
|
292
335
|
await handleSystemApi(req, res, url, {
|
|
293
336
|
getSystemStatus,
|
|
294
337
|
requestRestart,
|
|
338
|
+
getPackageInfo: () => getPackageInfo(projectRoot),
|
|
339
|
+
checkForUpdates: () => checkForUpdates(projectRoot),
|
|
340
|
+
updateQuickForge,
|
|
341
|
+
isLocalRequest: isLoopbackAddress(req.socket.remoteAddress),
|
|
295
342
|
getTerminalShellSetting: readTerminalShellSetting,
|
|
296
343
|
updateTerminalShellSetting,
|
|
297
344
|
getTerminalShellConfig: readTerminalShellConfig,
|
|
@@ -532,6 +579,7 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
532
579
|
})
|
|
533
580
|
|
|
534
581
|
await ensureStorage()
|
|
582
|
+
initializeChannels({ projectRoot })
|
|
535
583
|
await resetStaleTaskStatuses()
|
|
536
584
|
await initializeActiveProject()
|
|
537
585
|
setActiveWorkspaceRootForFilesystem(getWorkspaceRoot())
|
|
@@ -577,6 +625,7 @@ async function gracefulShutdown(signal) {
|
|
|
577
625
|
stopVite()
|
|
578
626
|
await shutdownAgentManager()
|
|
579
627
|
await shutdownMcpConnections()
|
|
628
|
+
await shutdownChannels()
|
|
580
629
|
shutdownTerminalSessions()
|
|
581
630
|
flushLogger()
|
|
582
631
|
process.exit(0)
|
|
@@ -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
|
|