@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.
Files changed (59) 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-evITXh-m.js → monaco-CGq6uVF1.js} +1 -1
  16. package/dist/assets/{react-vendor-Mthyt1p4.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 +198 -32
  23. package/server/agent-profile-files.mjs +179 -0
  24. package/server/agent-profiles.mjs +59 -5
  25. package/server/auto-compaction.mjs +82 -39
  26. package/server/channels/process-channel.mjs +278 -0
  27. package/server/channels/providers/wechat.mjs +271 -0
  28. package/server/channels/registry.mjs +58 -0
  29. package/server/custom-commands.mjs +13 -1
  30. package/server/frontmatter.mjs +167 -0
  31. package/server/index.mjs +52 -3
  32. package/server/project-config.mjs +43 -6
  33. package/server/routes/agent-profiles.mjs +6 -2
  34. package/server/routes/agent.mjs +12 -1
  35. package/server/routes/channels.mjs +145 -0
  36. package/server/routes/models.mjs +68 -0
  37. package/server/routes/project.mjs +2 -2
  38. package/server/routes/scheduled-tasks.mjs +6 -5
  39. package/server/routes/storage.mjs +4 -2
  40. package/server/routes/system.mjs +27 -0
  41. package/server/routes/tools.mjs +17 -6
  42. package/server/routes/workspace.mjs +138 -0
  43. package/server/session-utils.mjs +10 -2
  44. package/server/storage.mjs +29 -2
  45. package/server/system-prompt.mjs +1 -0
  46. package/server/tools/definitions.mjs +18 -0
  47. package/server/tools/index.mjs +83 -0
  48. package/server/utils/package-update.mjs +156 -0
  49. package/dist/assets/AgentProfilesPage-CNK5PxA3.js +0 -1
  50. package/dist/assets/ChatPanelHost-FqPQwwMO.js +0 -217
  51. package/dist/assets/PluginsPage-BCu1Ept0.js +0 -1
  52. package/dist/assets/ScheduledTasksPage-Bx04rjui.js +0 -2
  53. package/dist/assets/SharedConversationPage-55vX9sqe.js +0 -1
  54. package/dist/assets/TerminalDock-DLN_pLkJ.js +0 -2
  55. package/dist/assets/WorkspaceInspector-DoemHHnY.js +0 -3
  56. package/dist/assets/WorkspaceReaderDialog-C6xUHBCw.js +0 -6
  57. package/dist/assets/icons-BWtivFsx.js +0 -1
  58. package/dist/assets/index-CxOHP41X.css +0 -3
  59. package/dist/assets/index-Dcf73EL8.js +0 -895
@@ -0,0 +1,271 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { promises as fs } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { ProcessChannelProvider } from '../process-channel.mjs'
5
+ import { getDefaultWorkspaceRoot, readProjectConfig } from '../../project-config.mjs'
6
+
7
+ const WEIXIN_ACP_PACKAGE = 'weixin-acp'
8
+ const MIN_NODE_MAJOR = 22
9
+ const ACTION_TIMEOUT_MS = 120_000
10
+ const DEFAULT_WORKSPACE_ID = 'default'
11
+
12
+ function npxCommand() {
13
+ return process.platform === 'win32' ? 'npx.cmd' : 'npx'
14
+ }
15
+
16
+ function shouldUseShellForNpx() {
17
+ return process.platform === 'win32'
18
+ }
19
+
20
+ function nodeMajor() {
21
+ return Number(process.versions.node.split('.')[0] || 0)
22
+ }
23
+
24
+ function qfAcpCommand(projectRoot) {
25
+ const nodeCommand = process.platform === 'win32' ? 'node' : process.execPath
26
+ return [nodeCommand, path.join(projectRoot, 'bin', 'quickforge.mjs'), 'acp']
27
+ }
28
+
29
+ function workspaceSummary(id, name, workspacePath, kind) {
30
+ return {
31
+ id,
32
+ name,
33
+ path: workspacePath,
34
+ kind,
35
+ }
36
+ }
37
+
38
+ async function assertDirectoryExists(workspacePath) {
39
+ try {
40
+ const stat = await fs.stat(workspacePath)
41
+ if (stat.isDirectory()) return
42
+ } catch {
43
+ // Report a clear channel action error below.
44
+ }
45
+ const error = new Error(`Workspace is not a directory: ${workspacePath}`)
46
+ error.statusCode = 400
47
+ throw error
48
+ }
49
+
50
+ async function resolveLaunchWorkspace(options = {}) {
51
+ const requestedId = typeof options.projectId === 'string' ? options.projectId : DEFAULT_WORKSPACE_ID
52
+ if (!requestedId || requestedId === DEFAULT_WORKSPACE_ID) {
53
+ const workspacePath = getDefaultWorkspaceRoot()
54
+ await assertDirectoryExists(workspacePath)
55
+ return workspaceSummary(DEFAULT_WORKSPACE_ID, 'workspace', workspacePath, 'default')
56
+ }
57
+
58
+ const config = await readProjectConfig()
59
+ const project = config.projects.find((item) => item.id === requestedId)
60
+ if (!project) {
61
+ const error = new Error(`Unknown project: ${requestedId}`)
62
+ error.statusCode = 404
63
+ throw error
64
+ }
65
+ await assertDirectoryExists(project.path)
66
+ return workspaceSummary(project.id, project.name, project.path, 'project')
67
+ }
68
+
69
+ function extractUrl(text) {
70
+ const matches = String(text).match(/https?:\/\/[^\s\u001b]+/g)
71
+ if (!matches?.length) return null
72
+ return matches[matches.length - 1].replace(/[),,。]+$/u, '')
73
+ }
74
+
75
+ function looksLikeTerminalQr(text) {
76
+ return /[█▀▄]{6,}/u.test(text) || /[▄▀]{6,}/u.test(text)
77
+ }
78
+
79
+ function runCommand(command, args, options = {}) {
80
+ return new Promise((resolve, reject) => {
81
+ const child = spawn(command, args, {
82
+ cwd: options.cwd,
83
+ env: options.env || process.env,
84
+ windowsHide: true,
85
+ shell: options.shell === true,
86
+ stdio: ['ignore', 'pipe', 'pipe'],
87
+ })
88
+ let stdout = ''
89
+ let stderr = ''
90
+ let settled = false
91
+ const timer = setTimeout(() => {
92
+ if (settled) return
93
+ child.kill('SIGTERM')
94
+ settled = true
95
+ const error = new Error(`Command timed out after ${options.timeoutMs || ACTION_TIMEOUT_MS}ms`)
96
+ error.stdout = stdout
97
+ error.stderr = stderr
98
+ reject(error)
99
+ }, options.timeoutMs || ACTION_TIMEOUT_MS)
100
+
101
+ child.stdout?.on('data', (chunk) => {
102
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
103
+ stdout += text
104
+ options.onStdout?.(text)
105
+ })
106
+ child.stderr?.on('data', (chunk) => {
107
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
108
+ stderr += text
109
+ options.onStderr?.(text)
110
+ })
111
+ child.once('error', (error) => {
112
+ if (settled) return
113
+ settled = true
114
+ clearTimeout(timer)
115
+ reject(error)
116
+ })
117
+ child.once('exit', (code, signal) => {
118
+ if (settled) return
119
+ settled = true
120
+ clearTimeout(timer)
121
+ if (code === 0) {
122
+ resolve({ stdout, stderr, code, signal })
123
+ } else {
124
+ const error = new Error(`Command exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`)
125
+ error.stdout = stdout
126
+ error.stderr = stderr
127
+ error.code = code
128
+ error.signal = signal
129
+ reject(error)
130
+ }
131
+ })
132
+ })
133
+ }
134
+
135
+ export class WechatChannelProvider extends ProcessChannelProvider {
136
+ constructor({ projectRoot }) {
137
+ super({
138
+ id: 'wechat',
139
+ name: '微信',
140
+ description: '通过 weixin-acp 将微信消息接入 QuickForge ACP Agent。',
141
+ kind: 'process',
142
+ provider: 'weixin-acp',
143
+ icon: 'wechat',
144
+ commandLabel: 'npx weixin-acp start -- qf acp',
145
+ supportsWorkspaceSelection: true,
146
+ actions: [
147
+ { id: 'logout', label: '退出登录', destructive: true },
148
+ { id: 'relogin', label: '重新登录' },
149
+ ],
150
+ requirements: [`Node.js >= ${MIN_NODE_MAJOR}`, 'npm/npx 可用', '首次启动需要微信扫码登录'],
151
+ })
152
+ this.projectRoot = projectRoot
153
+ this.launchWorkspace = null
154
+ }
155
+
156
+ snapshot() {
157
+ return {
158
+ ...super.snapshot(),
159
+ launchWorkspace: this.launchWorkspace,
160
+ }
161
+ }
162
+
163
+ async beforeStart(options = {}) {
164
+ if (nodeMajor() < MIN_NODE_MAJOR) {
165
+ const message = `微信渠道需要 Node.js >= ${MIN_NODE_MAJOR},当前版本是 ${process.versions.node}。请升级 Node 后重试。`
166
+ this.error = message
167
+ this.setStatus('error', { error: message })
168
+ const error = new Error(message)
169
+ error.statusCode = 409
170
+ throw error
171
+ }
172
+ this.launchWorkspace = await resolveLaunchWorkspace(options)
173
+ }
174
+
175
+ buildStartCommand() {
176
+ const [acpCommand, ...acpArgs] = qfAcpCommand(this.projectRoot)
177
+ return {
178
+ command: npxCommand(),
179
+ args: ['-y', WEIXIN_ACP_PACKAGE, 'start', '--', acpCommand, ...acpArgs],
180
+ cwd: this.launchWorkspace?.path || getDefaultWorkspaceRoot() || this.projectRoot,
181
+ env: process.env,
182
+ shell: shouldUseShellForNpx(),
183
+ }
184
+ }
185
+
186
+ inspectOutput(text) {
187
+ const url = extractUrl(text)
188
+ if (url) {
189
+ this.qrCodeUrl = url
190
+ if (this.status === 'starting') this.setStatus('waiting_scan')
191
+ this.emitEvent('qrcode', { qrCodeUrl: this.qrCodeUrl, qrCodeText: this.qrCodeText, snapshot: this.snapshot() })
192
+ }
193
+
194
+ if (looksLikeTerminalQr(text)) {
195
+ const nextText = `${this.qrCodeText}\n${text}`.trim()
196
+ this.qrCodeText = nextText.length > 8000 ? nextText.slice(-8000) : nextText
197
+ if (this.status === 'starting') this.setStatus('waiting_scan')
198
+ this.emitEvent('qrcode', { qrCodeUrl: this.qrCodeUrl, qrCodeText: this.qrCodeText, snapshot: this.snapshot() })
199
+ }
200
+
201
+ if (/扫码|二维码|等待扫码|scan/i.test(text) && this.status === 'starting') {
202
+ this.setStatus('waiting_scan')
203
+ }
204
+
205
+ if (/与微信连接成功|启动 bot|\[weixin\] 启动 bot|connected|login success/i.test(text)) {
206
+ this.qrCodeText = ''
207
+ this.setStatus('running')
208
+ }
209
+ }
210
+
211
+ inspectOutputLine(line) {
212
+ if (/与微信连接成功|启动 bot|\[weixin\] 启动 bot|connected|login success/i.test(line)) {
213
+ this.qrCodeText = ''
214
+ this.setStatus('running')
215
+ }
216
+ }
217
+
218
+ async runAction(action, options = {}) {
219
+ if (action === 'logout') return this.logout()
220
+ if (action === 'relogin') return this.relogin(options)
221
+ return super.runAction(action, options)
222
+ }
223
+
224
+ async logout({ preserveActiveAction = false } = {}) {
225
+ if (this.activeAction && !preserveActiveAction) return this.snapshot()
226
+ if (!preserveActiveAction) this.activeAction = 'logout'
227
+ this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
228
+ try {
229
+ if (this.process) await this.stop()
230
+ this.addLog('system', 'Running: npx weixin-acp logout')
231
+ await runCommand(npxCommand(), ['-y', WEIXIN_ACP_PACKAGE, 'logout'], {
232
+ cwd: this.projectRoot,
233
+ env: process.env,
234
+ shell: shouldUseShellForNpx(),
235
+ onStdout: (text) => this.addProcessText('stdout', text),
236
+ onStderr: (text) => this.addProcessText('stderr', text),
237
+ })
238
+ this.qrCodeUrl = null
239
+ this.qrCodeText = ''
240
+ this.error = null
241
+ this.setStatus('stopped')
242
+ return this.snapshot()
243
+ } catch (error) {
244
+ const message = error instanceof Error ? error.message : String(error)
245
+ this.error = message
246
+ this.addLog('stderr', message)
247
+ this.setStatus('error', { error: message })
248
+ throw error
249
+ } finally {
250
+ if (!preserveActiveAction) this.activeAction = null
251
+ this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
252
+ }
253
+ }
254
+
255
+ async relogin(options = {}) {
256
+ if (this.activeAction) return this.snapshot()
257
+ this.activeAction = 'relogin'
258
+ this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
259
+ try {
260
+ await this.logout({ preserveActiveAction: true })
261
+ return await this.start({ projectId: options.projectId || this.launchWorkspace?.id || DEFAULT_WORKSPACE_ID })
262
+ } finally {
263
+ this.activeAction = null
264
+ this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
265
+ }
266
+ }
267
+ }
268
+
269
+ export function createWechatChannelProvider(options) {
270
+ return new WechatChannelProvider(options)
271
+ }
@@ -0,0 +1,58 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { createWechatChannelProvider } from './providers/wechat.mjs'
3
+
4
+ export const channelEvents = new EventEmitter()
5
+
6
+ const providers = new Map()
7
+ let initialized = false
8
+
9
+ function attachProvider(provider) {
10
+ providers.set(provider.definition.id, provider)
11
+ provider.on('event', (event) => {
12
+ channelEvents.emit('channel_event', event)
13
+ })
14
+ }
15
+
16
+ export function initializeChannels(options = {}) {
17
+ if (initialized) return
18
+ initialized = true
19
+ attachProvider(createWechatChannelProvider({ projectRoot: options.projectRoot }))
20
+ }
21
+
22
+ function requireProvider(id) {
23
+ const provider = providers.get(id)
24
+ if (!provider) {
25
+ const error = new Error(`Unknown channel: ${id}`)
26
+ error.statusCode = 404
27
+ throw error
28
+ }
29
+ return provider
30
+ }
31
+
32
+ export function listChannels() {
33
+ return Array.from(providers.values()).map((provider) => provider.snapshot())
34
+ }
35
+
36
+ export function getChannelStatus(id) {
37
+ return requireProvider(id).snapshot()
38
+ }
39
+
40
+ export async function startChannel(id, options = {}) {
41
+ return requireProvider(id).start(options)
42
+ }
43
+
44
+ export async function stopChannel(id) {
45
+ return requireProvider(id).stop()
46
+ }
47
+
48
+ export async function restartChannel(id, options = {}) {
49
+ return requireProvider(id).restart(options)
50
+ }
51
+
52
+ export async function runChannelAction(id, action, options = {}) {
53
+ return requireProvider(id).runAction(action, options)
54
+ }
55
+
56
+ export async function shutdownChannels() {
57
+ await Promise.allSettled(Array.from(providers.values()).map((provider) => provider.stop()))
58
+ }
@@ -26,9 +26,14 @@ const builtinCommandCatalog = [
26
26
  argumentHint: '[scope]',
27
27
  permissionNote: 'no edits',
28
28
  },
29
+ {
30
+ name: 'summary',
31
+ description: 'Create a new chat with this conversation summarized to reduce context usage.',
32
+ argumentHint: '',
33
+ },
29
34
  {
30
35
  name: 'compact',
31
- description: 'Create a new chat with this conversation compacted to reduce context usage.',
36
+ description: 'Compact this conversation context in place using the same rolling summary as auto-compaction.',
32
37
  argumentHint: '',
33
38
  },
34
39
  {
@@ -373,6 +378,9 @@ export function parseInternalCommandInvocation(message) {
373
378
  const compactMatch = text.match(/^\/compact(?:\s+([\s\S]*))?$/i)
374
379
  if (compactMatch) return { type: 'compact', args: (compactMatch[1] || '').trim() }
375
380
 
381
+ const summaryMatch = text.match(/^\/summary(?:\s+([\s\S]*))?$/i)
382
+ if (summaryMatch) return { type: 'summary', args: (summaryMatch[1] || '').trim() }
383
+
376
384
  const createMatch = text.match(/^\/command\s+new\s+([A-Za-z0-9][A-Za-z0-9-]*)\s*$/i)
377
385
  if (createMatch) {
378
386
  const name = normalizeCommandName(createMatch[1])
@@ -389,6 +397,10 @@ export async function handleInternalCommand(invocation, workspaceRoot, commandDi
389
397
  return { compact: true, args: invocation.args || '' }
390
398
  }
391
399
 
400
+ if (invocation.type === 'summary') {
401
+ return { summary: true, args: invocation.args || '' }
402
+ }
403
+
392
404
  if (invocation.type === 'plan') {
393
405
  if (!invocation.args) return 'Usage: /plan <task>'
394
406
  return { plan: true, args: invocation.args }
@@ -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 || projectRoot)
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)