@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
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto'
2
2
  import { readStore, atomicUpdate } from './storage.mjs'
3
3
  import { subagentDefinitions } from './subagents.mjs'
4
4
  import { workspaceTools } from './tools/definitions.mjs'
5
+ import { defaultGlobalWorkspaceContext, projectContextFromId } from './project-config.mjs'
6
+ import { loadFileAgentProfiles } from './agent-profile-files.mjs'
5
7
 
6
8
  const STORE = 'custom-agents'
7
9
  const RESERVED_NAMES = new Set(subagentDefinitions.map((definition) => definition.name))
@@ -56,6 +58,9 @@ function builtinProfileFromSubagent(definition) {
56
58
  maxToolCalls: definition.maxToolCalls || DEFAULT_MAX_TOOL_CALLS,
57
59
  enabledAsSubagent: true,
58
60
  builtin: true,
61
+ source: 'builtin',
62
+ readonly: true,
63
+ allowFileMutations: definition.allowFileMutations === true,
59
64
  createdAt: 'builtin',
60
65
  updatedAt: 'builtin',
61
66
  }
@@ -92,6 +97,9 @@ function normalizeProfileInput(input, existing = null, { creating = false } = {}
92
97
  maxToolCalls: normalizeOptionalPositiveInteger(input?.maxToolCalls ?? existing?.maxToolCalls, DEFAULT_MAX_TOOL_CALLS, 300),
93
98
  enabledAsSubagent: input?.enabledAsSubagent === undefined ? Boolean(existing?.enabledAsSubagent ?? true) : input.enabledAsSubagent === true,
94
99
  builtin: false,
100
+ source: 'store',
101
+ readonly: false,
102
+ allowFileMutations: allowedTools.some((toolName) => toolName === 'write_file' || toolName === 'edit_file'),
95
103
  createdAt: existing?.createdAt || now,
96
104
  updatedAt: now,
97
105
  }
@@ -102,20 +110,61 @@ async function readCustomAgentMap() {
102
110
  return data && typeof data === 'object' ? data : {}
103
111
  }
104
112
 
113
+ async function resolveWorkspaceRoot(options = {}) {
114
+ if (options.workspaceRoot) return options.workspaceRoot
115
+ if (options.projectId) {
116
+ try {
117
+ return (await projectContextFromId(options.projectId))?.workspaceRoot || null
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+ return defaultGlobalWorkspaceContext()?.workspaceRoot || null
123
+ }
124
+
125
+ function mergeProfiles({ builtin = [], file = [], custom = [] }) {
126
+ const reservedNames = new Set(builtin.map((profile) => profile.name))
127
+ const byName = new Map()
128
+
129
+ for (const profile of builtin) {
130
+ byName.set(profile.name, profile)
131
+ }
132
+ for (const profile of file) {
133
+ if (!profile?.name || reservedNames.has(profile.name)) continue
134
+ byName.set(profile.name, profile)
135
+ }
136
+ for (const profile of custom) {
137
+ if (!profile?.id) continue
138
+ if (!reservedNames.has(profile.name) && !byName.has(profile.name)) byName.set(profile.name, profile)
139
+ }
140
+
141
+ return [...byName.values()].sort((a, b) => {
142
+ if (a.builtin && !b.builtin) return -1
143
+ if (!a.builtin && b.builtin) return 1
144
+ return a.name.localeCompare(b.name)
145
+ })
146
+ }
147
+
105
148
  export async function listAgentProfiles(options = {}) {
106
149
  const custom = Object.values(await readCustomAgentMap())
107
- const profiles = [...listBuiltinAgentProfiles(), ...custom]
150
+ const workspaceRoot = await resolveWorkspaceRoot(options)
151
+ const file = await loadFileAgentProfiles(workspaceRoot, { reservedNames: RESERVED_NAMES })
152
+ const profiles = mergeProfiles({ builtin: listBuiltinAgentProfiles(), file, custom })
108
153
  return options.includeDisabled ? profiles : profiles.filter((profile) => profile.enabledAsSubagent || profile.builtin || profile.enabledAsSubagent === false)
109
154
  }
110
155
 
111
- export async function listSubagentProfiles() {
112
- return (await listAgentProfiles({ includeDisabled: true })).filter((profile) => profile.enabledAsSubagent)
156
+ export async function listSubagentProfiles(options = {}) {
157
+ return (await listAgentProfiles({ ...options, includeDisabled: true })).filter((profile) => profile.enabledAsSubagent)
113
158
  }
114
159
 
115
- export async function getAgentProfile(idOrName) {
160
+ export async function getAgentProfile(idOrName, options = {}) {
116
161
  const key = String(idOrName || '').trim().toLowerCase()
117
162
  if (!key) return null
118
- return (await listAgentProfiles({ includeDisabled: true })).find((profile) => profile.id === key || profile.name === key) || null
163
+ const profiles = await listAgentProfiles({ ...options, includeDisabled: true })
164
+ const byName = profiles.find((profile) => profile.name === key)
165
+ if (byName) return byName
166
+ const custom = Object.values(await readCustomAgentMap())
167
+ return custom.find((profile) => profile?.id === key) || profiles.find((profile) => profile.id === key) || null
119
168
  }
120
169
 
121
170
  export async function createCustomAgentProfile(input) {
@@ -167,7 +216,12 @@ export function agentProfileSnapshot(profile) {
167
216
  allowedTools: [...profile.allowedTools],
168
217
  maxRuntimeMs: profile.maxRuntimeMs,
169
218
  maxToolCalls: profile.maxToolCalls,
219
+ enabledAsSubagent: profile.enabledAsSubagent === true,
170
220
  builtin: profile.builtin === true,
221
+ source: profile.source || (profile.builtin ? 'builtin' : 'store'),
222
+ readonly: profile.readonly === true || profile.builtin === true,
223
+ filePath: profile.filePath,
224
+ relativePath: profile.relativePath,
171
225
  }
172
226
  }
173
227
 
@@ -151,6 +151,76 @@ export function buildAutoCompactLoopMessages(session, messages) {
151
151
  return [summaryMessage, ...source.slice(compactedUpToIndex)]
152
152
  }
153
153
 
154
+ export async function compactSessionInPlace({
155
+ session,
156
+ messages,
157
+ keepRecentTurns = DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns,
158
+ minSourceChars = DEFAULT_AUTO_COMPACT_SETTINGS.minSourceChars,
159
+ usage,
160
+ thresholdPercent,
161
+ emitSessionEvent,
162
+ persistSession,
163
+ reason = 'manual_compact',
164
+ summaryIntro = 'The previous conversation has been compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
165
+ onBeforePersist,
166
+ }) {
167
+ const source = Array.isArray(messages) ? messages : []
168
+ const normalizedKeepRecentTurns = clampNumber(keepRecentTurns, DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns, 1, 20)
169
+ const normalizedMinSourceChars = clampNumber(minSourceChars, DEFAULT_AUTO_COMPACT_SETTINGS.minSourceChars, 0, 200000)
170
+ const tailStart = tailStartForRecentTurns(source, normalizedKeepRecentTurns)
171
+ const sourceMessages = buildCompactionSourceMessages(session, source, tailStart)
172
+ if (sourceMessages.length < 2 || estimateMessagesChars(sourceMessages) < normalizedMinSourceChars) {
173
+ return { compacted: false, usage, reason: 'not_enough_history' }
174
+ }
175
+
176
+ const result = await compactConversation({
177
+ messages: sourceMessages,
178
+ model: session.model,
179
+ thinkingLevel: session.thinkingLevel,
180
+ getApiKey: session.getApiKey,
181
+ keepTurns: 0,
182
+ })
183
+
184
+ if (result.skipped) return { compacted: false, usage, reason: result.reason || 'skipped' }
185
+
186
+ await saveCompactBackup(session.sessionId, sourceMessages)
187
+ const summaryMessage = userTextMessage([
188
+ summaryIntro,
189
+ '',
190
+ '<compact_summary>',
191
+ result.summary,
192
+ '</compact_summary>',
193
+ ].join('\n'))
194
+ session.contextCompaction = {
195
+ summaryMessage,
196
+ compactedUpToIndex: tailStart,
197
+ compactedAt: new Date().toISOString(),
198
+ keepRecentTurns: normalizedKeepRecentTurns,
199
+ sourceMessageCount: source.length,
200
+ usageBefore: usage,
201
+ thresholdPercent,
202
+ }
203
+ onBeforePersist?.({ result, sourceMessages, tailStart, summaryMessage })
204
+ await persistSession?.(session)
205
+ const contextUsage = estimateSessionContextUsage(session, source)
206
+ emitSessionEvent?.(session, {
207
+ type: 'auto_compact_completed',
208
+ reason,
209
+ usage,
210
+ thresholdPercent,
211
+ contextCompaction: session.contextCompaction,
212
+ contextUsage,
213
+ })
214
+ emitSessionEvent?.(session, {
215
+ type: 'messages_replaced',
216
+ reason,
217
+ messages: source,
218
+ contextCompaction: session.contextCompaction,
219
+ contextUsage,
220
+ })
221
+ return { compacted: true, usage, result, sourceMessages, tailStart }
222
+ }
223
+
154
224
  export function estimateSessionContextUsage(session, messages = session?.agent?.state?.messages ?? []) {
155
225
  if (!session?.agent?.state) return null
156
226
  const sourceMessages = Array.isArray(messages) ? messages : []
@@ -274,50 +344,23 @@ export async function maybeAutoCompactSession({ session, messages, signal, emitS
274
344
 
275
345
  session.autoCompacting = true
276
346
  try {
277
- const result = await compactConversation({
278
- messages: sourceMessages,
279
- model: session.model,
280
- thinkingLevel: session.thinkingLevel,
281
- getApiKey: session.getApiKey,
282
- keepTurns: 0,
283
- })
284
-
285
- if (result.skipped) return { compacted: false, usage, reason: result.reason || 'skipped' }
286
-
287
- await saveCompactBackup(session.sessionId, sourceMessages)
288
- const summaryMessage = userTextMessage([
289
- 'The previous conversation has been automatically compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
290
- '',
291
- '<compact_summary>',
292
- result.summary,
293
- '</compact_summary>',
294
- ].join('\n'))
295
- session.contextCompaction = {
296
- summaryMessage,
297
- compactedUpToIndex: tailStart,
298
- compactedAt: new Date().toISOString(),
347
+ const result = await compactSessionInPlace({
348
+ session,
349
+ messages,
299
350
  keepRecentTurns: settings.keepRecentTurns,
300
- sourceMessageCount: messages.length,
301
- usageBefore: usage,
302
- thresholdPercent: settings.thresholdPercent,
303
- }
304
- clearAutoCompactRejected(session)
305
- session.lastAutoCompactAt = now
306
- await persistSession(session)
307
- emitSessionEvent(session, {
308
- type: 'auto_compact_completed',
351
+ minSourceChars: settings.minSourceChars,
309
352
  usage,
310
353
  thresholdPercent: settings.thresholdPercent,
311
- contextCompaction: session.contextCompaction,
312
- contextUsage: estimateSessionContextUsage(session, messages),
313
- })
314
- emitSessionEvent(session, {
315
- type: 'messages_replaced',
354
+ emitSessionEvent,
355
+ persistSession,
316
356
  reason: 'auto_compact',
317
- messages,
318
- contextCompaction: session.contextCompaction,
319
- contextUsage: estimateSessionContextUsage(session, messages),
357
+ summaryIntro: 'The previous conversation has been automatically compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
358
+ onBeforePersist: () => {
359
+ clearAutoCompactRejected(session)
360
+ session.lastAutoCompactAt = now
361
+ },
320
362
  })
363
+ if (!result.compacted) return result
321
364
  return { compacted: true, usage }
322
365
  } catch (error) {
323
366
  logger?.warn?.(`Auto compact failed for session ${session.sessionId}:`, error?.message || error, { sessionId: session.sessionId })
@@ -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
+ }