@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
@@ -0,0 +1,278 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { EventEmitter } from 'node:events'
3
+ import { setTimeout as delay } from 'node:timers/promises'
4
+
5
+ const DEFAULT_LOG_LIMIT = 300
6
+ const STOP_TIMEOUT_MS = 5000
7
+
8
+ function nowIso() {
9
+ return new Date().toISOString()
10
+ }
11
+
12
+ function normalizeChunk(chunk) {
13
+ return Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
14
+ }
15
+
16
+ function stripAnsi(value) {
17
+ return String(value).replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
18
+ }
19
+
20
+ function appendLineBuffer(buffer, text, onLine) {
21
+ const combined = buffer + text
22
+ const lines = combined.split(/\r?\n/)
23
+ const nextBuffer = lines.pop() || ''
24
+ for (const line of lines) onLine(line)
25
+ return nextBuffer
26
+ }
27
+
28
+ function runDetachedCommand(command, args) {
29
+ return new Promise((resolve) => {
30
+ const child = spawn(command, args, { windowsHide: true, stdio: 'ignore' })
31
+ child.once('error', () => resolve(false))
32
+ child.once('exit', (code) => resolve(code === 0))
33
+ })
34
+ }
35
+
36
+ async function terminateProcess(child, signal = 'SIGTERM') {
37
+ if (!child?.pid) return false
38
+ if (process.platform === 'win32') {
39
+ return runDetachedCommand('taskkill.exe', ['/PID', String(child.pid), '/T', signal === 'SIGKILL' ? '/F' : undefined].filter(Boolean))
40
+ }
41
+ try {
42
+ return process.kill(-child.pid, signal)
43
+ } catch {
44
+ try {
45
+ return child.kill(signal)
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+ }
51
+
52
+ export class ProcessChannelProvider extends EventEmitter {
53
+ constructor(definition, options = {}) {
54
+ super()
55
+ this.definition = definition
56
+ this.logLimit = options.logLimit || DEFAULT_LOG_LIMIT
57
+ this.status = 'stopped'
58
+ this.process = null
59
+ this.pid = null
60
+ this.startedAt = null
61
+ this.stoppedAt = null
62
+ this.exitCode = null
63
+ this.exitSignal = null
64
+ this.error = null
65
+ this.logs = []
66
+ this.qrCodeUrl = null
67
+ this.qrCodeText = ''
68
+ this.activeAction = null
69
+ this.stopRequested = false
70
+ this.stdoutBuffer = ''
71
+ this.stderrBuffer = ''
72
+ this.startPromise = null
73
+ this.stopPromise = null
74
+ }
75
+
76
+ snapshot() {
77
+ return {
78
+ id: this.definition.id,
79
+ name: this.definition.name,
80
+ description: this.definition.description,
81
+ kind: this.definition.kind || 'process',
82
+ provider: this.definition.provider,
83
+ icon: this.definition.icon,
84
+ commandLabel: this.definition.commandLabel,
85
+ supportsWorkspaceSelection: this.definition.supportsWorkspaceSelection === true,
86
+ status: this.status,
87
+ pid: this.pid,
88
+ startedAt: this.startedAt,
89
+ stoppedAt: this.stoppedAt,
90
+ exitCode: this.exitCode,
91
+ exitSignal: this.exitSignal,
92
+ error: this.error,
93
+ logs: this.logs,
94
+ qrCodeUrl: this.qrCodeUrl,
95
+ qrCodeText: this.qrCodeText,
96
+ actions: this.definition.actions || [],
97
+ requirements: this.definition.requirements || [],
98
+ activeAction: this.activeAction,
99
+ }
100
+ }
101
+
102
+ emitEvent(type, payload = {}) {
103
+ const event = {
104
+ type,
105
+ channelId: this.definition.id,
106
+ timestamp: nowIso(),
107
+ ...payload,
108
+ }
109
+ this.emit('event', event)
110
+ }
111
+
112
+ setStatus(status, extra = {}) {
113
+ this.status = status
114
+ if (Object.prototype.hasOwnProperty.call(extra, 'error')) {
115
+ this.error = extra.error
116
+ }
117
+ this.emitEvent('status', { status, snapshot: this.snapshot() })
118
+ }
119
+
120
+ addLog(stream, text) {
121
+ if (!text) return
122
+ const entry = {
123
+ id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
124
+ time: nowIso(),
125
+ stream,
126
+ text,
127
+ }
128
+ this.logs.push(entry)
129
+ if (this.logs.length > this.logLimit) {
130
+ this.logs.splice(0, this.logs.length - this.logLimit)
131
+ }
132
+ this.emitEvent('log', { log: entry })
133
+ }
134
+
135
+ addProcessText(stream, text) {
136
+ const cleanText = stripAnsi(text).replace(/\r/g, '\n')
137
+ if (!cleanText) return
138
+ this.addLog(stream, cleanText)
139
+ this.inspectOutput(cleanText, stream)
140
+
141
+ const lineHandler = (line) => this.inspectOutputLine(line, stream)
142
+ if (stream === 'stderr') {
143
+ this.stderrBuffer = appendLineBuffer(this.stderrBuffer, cleanText, lineHandler)
144
+ } else {
145
+ this.stdoutBuffer = appendLineBuffer(this.stdoutBuffer, cleanText, lineHandler)
146
+ }
147
+ }
148
+
149
+ inspectOutput(_text, _stream) {
150
+ }
151
+
152
+ inspectOutputLine(_line, _stream) {
153
+ }
154
+
155
+ buildStartCommand() {
156
+ throw new Error('buildStartCommand() is not implemented')
157
+ }
158
+
159
+ async beforeStart(_options = {}) {}
160
+
161
+ async start(options = {}) {
162
+ if (this.process) return this.snapshot()
163
+ if (this.startPromise) return this.startPromise
164
+ if (this.status === 'stopping') return this.snapshot()
165
+
166
+ this.startPromise = this.#startProcess(options)
167
+ try {
168
+ return await this.startPromise
169
+ } finally {
170
+ this.startPromise = null
171
+ }
172
+ }
173
+
174
+ async #startProcess(options = {}) {
175
+ this.stopRequested = false
176
+ this.exitCode = null
177
+ this.exitSignal = null
178
+ this.error = null
179
+ this.qrCodeUrl = null
180
+ this.qrCodeText = ''
181
+ this.stdoutBuffer = ''
182
+ this.stderrBuffer = ''
183
+ this.startedAt = nowIso()
184
+ this.stoppedAt = null
185
+ this.setStatus('starting')
186
+
187
+ await this.beforeStart(options)
188
+ if (this.process) return this.snapshot()
189
+
190
+ const commandSpec = this.buildStartCommand(options)
191
+
192
+ const child = spawn(commandSpec.command, commandSpec.args || [], {
193
+ cwd: commandSpec.cwd,
194
+ env: commandSpec.env || process.env,
195
+ windowsHide: true,
196
+ detached: process.platform !== 'win32',
197
+ shell: commandSpec.shell === true,
198
+ stdio: ['ignore', 'pipe', 'pipe'],
199
+ })
200
+
201
+ this.process = child
202
+ this.pid = child.pid || null
203
+ this.emitEvent('status', { status: this.status, snapshot: this.snapshot() })
204
+
205
+ child.stdout?.on('data', (chunk) => this.addProcessText('stdout', normalizeChunk(chunk)))
206
+ child.stderr?.on('data', (chunk) => this.addProcessText('stderr', normalizeChunk(chunk)))
207
+
208
+ child.once('error', (error) => {
209
+ this.error = error.message || String(error)
210
+ this.addLog('stderr', this.error)
211
+ this.process = null
212
+ this.pid = null
213
+ this.stoppedAt = nowIso()
214
+ this.setStatus('error', { error: this.error })
215
+ })
216
+
217
+ child.once('exit', (code, signal) => {
218
+ this.process = null
219
+ this.pid = null
220
+ this.exitCode = code
221
+ this.exitSignal = signal
222
+ this.stoppedAt = nowIso()
223
+ if (this.stopRequested || code === 0) {
224
+ this.setStatus('stopped')
225
+ } else {
226
+ const reason = `Channel process exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`
227
+ this.error = reason
228
+ this.setStatus('error', { error: reason })
229
+ }
230
+ })
231
+
232
+ return this.snapshot()
233
+ }
234
+
235
+ async stop() {
236
+ if (this.stopPromise) return this.stopPromise
237
+ this.stopPromise = this.#stopProcess()
238
+ try {
239
+ return await this.stopPromise
240
+ } finally {
241
+ this.stopPromise = null
242
+ }
243
+ }
244
+
245
+ async #stopProcess() {
246
+ if (!this.process) {
247
+ this.setStatus('stopped')
248
+ return this.snapshot()
249
+ }
250
+
251
+ this.stopRequested = true
252
+ this.setStatus('stopping')
253
+ const child = this.process
254
+ await terminateProcess(child, 'SIGTERM')
255
+
256
+ const exited = await Promise.race([
257
+ new Promise((resolve) => child.once('exit', () => resolve(true))),
258
+ delay(STOP_TIMEOUT_MS).then(() => false),
259
+ ])
260
+
261
+ if (!exited && this.process === child) {
262
+ await terminateProcess(child, 'SIGKILL')
263
+ }
264
+
265
+ return this.snapshot()
266
+ }
267
+
268
+ async restart(options = {}) {
269
+ await this.stop()
270
+ return this.start(options)
271
+ }
272
+
273
+ async runAction(_action, _options = {}) {
274
+ const error = new Error('Unsupported channel action')
275
+ error.statusCode = 404
276
+ throw error
277
+ }
278
+ }
@@ -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
+ }